hushfox / game.js
CyberMax-tools's picture
Deploy hushfox
0cc8837 verified
Raw History Blame Contribute Delete
44 kB
// Hushfox UI: pass-the-phone party mode (works offline), room-code mode (each player on their own phone), and the
// solo Fox Hunt (daily + 30 levels). Rules live in core.js; the room runs on the hushfox Worker (net.js).
// (c) 2026 CyberMax. All rights reserved.
import * as C from './core.js';
import { PACKS, deck, packById } from './packs.js';
import { count, store, toast, share, pwa, themeToggle, canvasBlob, download, today, dayNumber, SITE } from './cmx.js';
import { progress, daily, readChallenge, challengeLink, shareChallenge, sfx, burstAt, shake, renderMap, scoreCard, starsFor, starsHtml, css as lvCss } from './levels.js';
import * as pass from './funpass.js';
import { apiBase, createRoom, roomInfo, connect } from './net.js';
const GAME = 'hushfox';
const PATH = '/games/hushfox/';
const stage = document.getElementById('stage');
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
let muted = !!store(`${GAME}:muted`);
const snd = sfx(() => muted);
const buzz = (p) => { try { if (!muted && navigator.vibrate) navigator.vibrate(p); } catch { /* */ } };
const FOX_SVG = '<svg class="fox" viewBox="0 0 512 512" aria-hidden="true"><path d="M106 132 196 226h120l90-94-26 168-124 112-124-112Z" fill="#FF7A2F"/><path d="M130 178 182 230l-30 30ZM382 178l-52 52 30 30Z" fill="#FFD9B8"/><path d="M132 300 256 412V330l-66-40ZM380 300 256 412V330l66-40Z" fill="#FFF3E6"/><circle cx="256" cy="396" r="19" fill="#1C1530"/><path d="M178 284q24 18 48 0M286 284q24 18 48 0" fill="none" stroke="#1C1530" stroke-width="15" stroke-linecap="round"/></svg>';
const fox = (cls = '') => FOX_SVG.replace('class="fox"', `class="fox ${cls}"`);
let tick = 0; // the one running interval (timers); cleared on every screen change
function show(html, cls = '') {
clearInterval(tick);
clearInterval(huntTick);
stage.innerHTML = `<div class="scr ${cls}">${html}</div>`;
stage.scrollTop = 0;
return stage.firstElementChild;
}
function on(id, fn) { const el = $(id); if (el) el.addEventListener('click', (e) => { snd.tap(); fn(e); }); return el; }
const isPro = () => pass.isPro();
// ------------------------------------------------------------------ shared bits
const LS_NAMES = `${GAME}:names`, LS_PACKS = `${GAME}:packs`, LS_ROUNDS = `${GAME}:rounds`, LS_PID = `${GAME}:pid`, LS_ME = `${GAME}:me`;
function pid() {
let id = store(LS_PID);
if (!id) { id = `p${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36).slice(-4)}`; store(LS_PID, id); }
return id;
}
function packChoice() {
const saved = store(LS_PACKS) || ['classic'];
const ok = saved.filter((id) => packById(id) && (!packById(id).pass || isPro()));
return ok.length ? ok : ['classic'];
}
function packsHtml(sel, canEdit = true) {
return `<div class="packs" role="group" aria-label="Packs">${PACKS.map((p) => {
const locked = p.pass && !isPro();
const n = p.cats.reduce((a, c) => a + c[2].split('|').length, 0);
return `<button type="button" class="pack" data-pack="${p.id}" aria-pressed="${sel.includes(p.id)}" ${canEdit ? '' : 'disabled'}><i>${p.emoji}</i><span><b>${esc(p.name)}</b><span>${esc(p.desc)} · ${n} words</span></span>${locked ? '<em>🔒 Fun Pass</em>' : p.pass ? '<em>✨ Pass</em>' : '<em>Free</em>'}</button>`;
}).join('')}</div>`;
}
function bindPacks(sel, onChange) {
stage.querySelectorAll('[data-pack]').forEach((b) => b.addEventListener('click', () => {
const p = packById(b.dataset.pack);
if (p.pass && !isPro()) { pass.open(`pack-${p.id}`); return; }
snd.tap();
const i = sel.indexOf(p.id);
if (i >= 0) { if (sel.length > 1) sel.splice(i, 1); } else sel.push(p.id);
stage.querySelectorAll('[data-pack]').forEach((x) => x.setAttribute('aria-pressed', String(sel.includes(x.dataset.pack))));
store(LS_PACKS, sel);
onChange && onChange(sel);
}));
}
function roundsHtml(n, canEdit = true) {
return `<div class="chips" role="group" aria-label="Rounds">${C.ROUND_OPTIONS.map((r) => `<button type="button" class="chip" data-rounds="${r}" aria-pressed="${r === n}" ${canEdit ? '' : 'disabled'}>${r} rounds</button>`).join('')}</div>`;
}
function bindRounds(onPick) {
stage.querySelectorAll('[data-rounds]').forEach((b) => b.addEventListener('click', () => {
snd.tap();
const n = Number(b.dataset.rounds);
stage.querySelectorAll('[data-rounds]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
store(LS_ROUNDS, n);
onPick(n);
}));
}
function timerHtml() {
return `<div class="timer" id="timer"><svg viewBox="0 0 120 120"><circle class="bg" cx="60" cy="60" r="52"/><circle class="fg" id="ring" cx="60" cy="60" r="52" stroke-dasharray="326.7" stroke-dashoffset="0"/></svg><b id="secs">30</b></div>`;
}
/** Drive the ring until `ends` (ms, local clock). onTwist fires once at TWIST_AT left; onEnd at zero. */
function runTimer(ends, total, { twistAt = 0, onTwist, onEnd } = {}) {
let twisted = !twistAt || Date.now() >= twistAt, lastSec = -1;
if (twisted && twistAt && onTwist) onTwist(true);
const step = () => {
const left = Math.max(0, ends - Date.now());
const s = Math.ceil(left / 1000);
const ring = $('ring'), secs = $('secs'), t = $('timer');
if (!ring) { clearInterval(tick); return; }
ring.style.strokeDashoffset = String(326.7 * (1 - left / total));
if (s !== lastSec) {
secs.textContent = s; lastSec = s;
t.classList.toggle('hurry', s <= 5 && s > 0);
if (s <= 5 && s > 0) snd.tone(520 + (5 - s) * 60, 0.05, 'square', 0.04, 1);
}
if (!twisted && twistAt && Date.now() >= twistAt) { twisted = true; onTwist && onTwist(false); }
if (left <= 0) { clearInterval(tick); onEnd && onEnd(); }
};
step();
tick = setInterval(step, 200);
}
function twistHtml(id, waiting = false) {
if (waiting) return '<div class="twist wait" id="twist"><i>🃏</i><span>Twist card flips at 15 seconds…</span></div>';
const t = C.twistById(id);
return `<div class="twist flip" id="twist"><i>${t.icon}</i><span>TWIST! ${esc(t.text)}</span></div>`;
}
function flipTwist(id, quiet) {
const el = $('twist');
if (el) el.outerHTML = twistHtml(id);
if (!quiet) { snd.boss(); buzz([60, 40, 60]); const t = $('twist'); if (t) burstAt(t, 0.5, 0.5, { colors: ['#FFE36B', '#FF7A2F', '#fff'], n: 24 }); }
}
// ------------------------------------------------------------------ home
function home() {
const dd = dayNumber(today());
show(`${fox()}<p class="logo">HUSH<i>FOX</i></p><p class="lead">Everyone knows the word. Except the Fox.</p>
<button class="big hot" id="h-pass" type="button"><b>📱 Pass one phone</b><span>3-12 players around one phone · works offline</span></button>
<button class="big" id="h-room" type="button"><b>🔗 Room code</b><span>Everyone joins on their own phone</span></button>
<button class="big" id="h-hunt" type="button"><b>🔎 Fox Hunt (solo)</b><span>Daily #${dd}: spot the word that doesn't belong · 30 levels</span></button>
<button class="lnk" id="h-how" type="button">How to play</button>`);
on('h-pass', () => passSetup());
on('h-room', () => roomHome());
on('h-hunt', () => huntHome());
on('h-how', () => howTo());
}
function howTo() {
show(`<p class="out">How to play</p>
<ol class="lead" style="text-align:left;font-weight:600;font-size:18px;padding-left:1.2em;max-width:34ch">
<li>Everyone secretly sees the word. The <b>Fox</b> only sees the category.</li>
<li>Go round the table: one clue each, out loud. Don't make it too easy!</li>
<li>At 15 seconds a <b>Twist card</b> flips (mime it, one syllable, opposites…). Do a second lap with the twist.</li>
<li>At zero, count 3-2-1 and point at the Fox.</li>
<li>Caught? The Fox can still <b>steal</b> the round by picking the word from six.</li></ol>
<p class="small muted2">Fox escapes: +2 · each finger on the Fox: +1 · Fox steals the word: +2</p>
<button class="btn go" id="b-back" type="button">Got it</button>`);
on('b-back', home);
}
// ------------------------------------------------------------------ pass-the-phone mode (offline)
let G = null; // the local game
function passSetup(keep) {
let names = keep || store(LS_NAMES) || ['', '', ''];
while (names.length < 3) names.push('');
let rounds = store(LS_ROUNDS) || 5;
const sel = packChoice();
let useDaily = false;
const draw = () => {
show(`<p class="out">Who's playing?</p>
<ul class="names" id="names">${names.map((n, i) => `<li><input class="inp" maxlength="16" aria-label="Player ${i + 1} name" placeholder="Player ${i + 1}" value="${esc(n)}" data-i="${i}">${names.length > 3 ? `<button class="x" type="button" data-del="${i}" aria-label="Remove player ${i + 1}">×</button>` : ''}</li>`).join('')}</ul>
${names.length < C.MAX_PLAYERS ? '<button class="btn ghost sm" id="add" type="button">+ Add player</button>' : ''}
<div class="field"><label>Rounds</label>${roundsHtml(rounds)}</div>
<div class="field"><label>Deck</label><div class="chips"><button type="button" class="chip" id="dly" aria-pressed="${useDaily}">📅 Today's deck #${dayNumber(today())} (same 5 words worldwide)</button></div></div>
<div class="field"><label>Packs</label>${packsHtml(sel)}</div>
<div class="field"><button class="btn ghost sm" id="ai" type="button">✨ Add a surprise AI category (optional)</button> <span class="small muted2" id="ai-note"></span></div>
<button class="btn go" id="start" type="button">▶ Start (${names.length} players)</button>
<button class="lnk" id="back" type="button">Back</button>`, 'top');
stage.querySelectorAll('[data-i]').forEach((el) => el.addEventListener('input', () => { names[Number(el.dataset.i)] = el.value; }));
stage.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => { names.splice(Number(b.dataset.del), 1); draw(); }));
on('add', () => { names.push(''); draw(); const l = stage.querySelectorAll('[data-i]'); l[l.length - 1].focus(); });
bindRounds((n) => { rounds = n; });
bindPacks(sel);
on('dly', () => { useDaily = !useDaily; $('dly').setAttribute('aria-pressed', String(useDaily)); if (useDaily) rounds = 5; });
on('ai', () => aiCategory());
on('back', home);
on('start', () => {
const clean = names.map((n, i) => C.cleanName(n || `Player ${i + 1}`));
const uniq = []; for (const n of clean) uniq.push(C.uniqueName(n, uniq));
store(LS_NAMES, uniq);
newPassGame(uniq, useDaily ? 5 : rounds, sel.slice(), useDaily);
});
if (aiExtra.length) $('ai-note').textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
};
draw();
}
let aiExtra = [];
async function aiCategory() {
const note = $('ai-note');
if (!navigator.onLine) { toast('Offline: the built-in decks work without internet.'); return; }
const theme = prompt('A theme for the surprise category (or leave empty for a random one):', '') ?? null;
if (theme === null) return;
note.textContent = 'Asking the AI…';
try {
const r = await fetch(`${apiBase()}/api/ai?theme=${encodeURIComponent(theme.slice(0, 30))}`);
const j = await r.json();
if (!r.ok || !j.cat) throw new Error(j.error || 'No category');
aiExtra = [...aiExtra.filter((x) => x.cat !== j.cat), { cat: j.cat, words: j.words }].slice(-3);
note.textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
count(GAME, 'cta', 'ai-category');
} catch (e) { note.textContent = `${e.message || 'AI is busy'}. The built-in decks are ready.`; }
}
function localDeck(packs) {
const d = deck(packs, isPro());
return [...(d.length ? d : deck(['classic'])), ...aiExtra.map((x) => ({ ...x, group: 'ai', pack: 'ai', lang: 'en' }))];
}
function newPassGame(names, rounds, packs, useDaily) {
G = { players: names.map((n, i) => ({ id: `s${i}`, name: n, score: 0 })), rounds, packs, useDaily, round: 0, history: [], used: new Set(), cur: null, seed: Math.random() };
count(GAME, 'play', useDaily ? 'pass-daily' : 'pass');
passDeal();
}
function passDeal() {
const ids = G.players.map((p) => p.id);
const r = C.rng(`${G.seed}-${G.round}-${Date.now()}`);
const d = localDeck(G.packs);
let cur = C.deal(d, ids, r, { used: G.used, round: G.round });
if (G.useDaily) {
const dr = C.dailyRounds(deck(['classic']), today())[G.round % 5];
const cat = deck(['classic']).find((c) => c.cat === dr.cat);
const others = C.shuffle(cat.words.filter((w) => w !== dr.word), r).slice(0, C.CHOICES - 1);
cur = { ...cur, cat: dr.cat, word: dr.word, twist: dr.twist, choices: C.shuffle([dr.word, ...others], r) };
}
// one shared phone can't show the Fox a private letter mid-round, so the peek twist is swapped for another
if (cur.twist === 'peek') cur.twist = C.TWISTS.filter((t) => t.id !== 'peek')[Math.floor(r() * (C.TWISTS.length - 1))].id;
G.cur = cur;
G.used.add(`${cur.cat}|${cur.word}`);
passTo(0);
}
const nameOf = (id) => (G.players.find((p) => p.id === id) || {}).name || '?';
function passTo(i) {
const p = G.players[i];
show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds} · card ${i + 1} of ${G.players.length}</p>
<p class="out">Pass the phone to</p><p class="code-big" style="letter-spacing:0;font-size:clamp(40px,12vw,64px)">${esc(p.name)}</p>
<p class="lead">Only ${esc(p.name)} looks!</p>
<button class="btn go" id="see" type="button">👀 I'm ${esc(p.name)}: show my card</button>`);
on('see', () => cardFor(i));
}
function cardFor(i) {
const p = G.players[i];
const c = C.cardFor(G.cur, p.id);
const last = i === G.players.length - 1;
show(`${c.fox ? `<div class="card foxcard flip"><p class="k">You are</p><p class="w">THE FOX 🦊</p><p class="c">Category: ${esc(c.cat)}</p><p class="c small">You don't know the word. Listen, blend in, give a vague clue.</p></div>`
: `<div class="card flip"><p class="k">${esc(c.cat)}</p><p class="w">${esc(c.word)}</p><p class="c small">Give a clue that proves you know it, without giving it away to the Fox.</p></div>`}
<button class="btn go" id="hide" type="button">${last ? '🙈 Hide & start the clues' : `🙈 Hide & pass to ${esc(G.players[i + 1].name)}`}</button>`);
if (c.fox) buzz([30, 30, 30]); else buzz(20);
on('hide', () => (last ? passClues() : passTo(i + 1)));
}
function passClues() {
const ends = Date.now() + C.CLUE_MS;
show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds}</p><p class="cat">${esc(G.cur.cat)}</p>
<p class="lead">🎤 ${esc(nameOf(G.cur.starter))} starts. One clue each, go round!</p>${timerHtml()}${twistHtml(G.cur.twist, true)}
<button class="btn ghost sm" id="skip" type="button">Everyone's done: vote now</button>`);
snd.win();
runTimer(ends, C.CLUE_MS, { twistAt: ends - C.TWIST_AT, onTwist: (q) => flipTwist(G.cur.twist, q), onEnd: () => { snd.hit(); buzz(200); passVote(); } });
on('skip', passVote);
}
function passVote() {
show(`<p class="out">Where's the Fox?</p><p class="lead">Count 3-2-1 and everyone points at once!</p>
<button class="btn go" id="count" type="button">3 · 2 · 1</button>
<div id="pick" hidden><p class="lead">Who got the most fingers?</p><div class="grid">${G.players.map((p) => `<button class="pick" type="button" data-acc="${p.id}">${esc(p.name)}</button>`).join('')}<button class="pick" type="button" data-acc="">🤷 A tie / split</button></div></div>`);
on('count', () => {
const b = $('count');
let n = 3;
b.disabled = true;
const t = setInterval(() => {
snd.tone(440 + (3 - n) * 120, 0.12, 'square', 0.05, 1);
b.textContent = n > 0 ? String(n) : 'POINT! 👉';
if (n-- <= 0) { clearInterval(t); buzz(120); $('pick').hidden = false; }
}, 550);
});
stage.querySelectorAll('[data-acc]').forEach((b) => b.addEventListener('click', () => {
snd.tap();
const acc = b.dataset.acc;
G.votes = {};
if (acc) for (const p of G.players) if (p.id !== acc) G.votes[p.id] = acc;
if (acc && acc === G.cur.fox) passGuess(); else passResult(null);
}));
}
function passGuess() {
show(`${fox('sm')}<p class="out">${esc(nameOf(G.cur.fox))} is the Fox!</p><p class="lead">Fox, one last chance: steal the round by picking the word.</p>
<div class="grid">${G.cur.choices.map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>`);
burstAt(stage.querySelector('.fox'), 0.5, 0.5);
snd.good(8);
stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => {
const ok = C.norm(b.dataset.w) === C.norm(G.cur.word);
b.classList.add(ok ? 'good' : 'bad');
if (!ok) stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`)?.classList.add('good');
setTimeout(() => passResult(b.dataset.w), 700);
}));
}
function passResult(guess) {
const s = C.score({ fox: G.cur.fox, word: G.cur.word, votes: G.votes || {}, guess });
for (const [id, n] of Object.entries(s.deltas)) G.players.find((p) => p.id === id).score += n;
G.history.push({ cat: G.cur.cat, word: G.cur.word, fox: G.cur.fox, foxName: nameOf(G.cur.fox), outcome: s.outcome });
resultScreen({ outcome: s.outcome, word: G.cur.word, foxName: nameOf(G.cur.fox), accusedName: s.accused ? nameOf(s.accused) : null, deltas: s.deltas, players: G.players, round: G.round, rounds: G.rounds },
{ next: () => { G.round++; if (G.round >= G.rounds) passFinal(); else passDeal(); } });
}
function resultScreen(r, { next, host = true }) {
const last = r.round + 1 >= r.rounds;
const sub = r.outcome === 'escaped' ? (r.accusedName ? `You pointed at ${esc(r.accusedName)}. Wrong!` : 'The table split, and the Fox slipped away.')
: r.outcome === 'stole' ? 'Caught… but the Fox guessed the word!' : r.outcome === 'void' ? 'The Fox left the room. Nobody scores.' : 'Every finger on the Fox scores a point.';
show(`<p class="emoji">${C.OUTCOME_EMOJI[r.outcome]}</p><p class="out">${esc(C.OUTCOME_TEXT[r.outcome])}</p><p class="lead">${sub}</p>
<div class="card"><p class="k">The word was</p><p class="w">${esc(r.word)}</p><p class="c">🦊 The Fox: ${esc(r.foxName)}</p></div>
<ul class="board">${[...r.players].sort((a, b) => b.score - a.score).map((p) => `<li>${esc(p.name)}${r.deltas[p.id] ? ` <span class="d">+${r.deltas[p.id]}</span>` : ''}<b>${p.score}</b></li>`).join('')}</ul>
${host ? `<button class="btn go" id="next" type="button">${last ? '🏆 Final scores' : `▶ Round ${r.round + 2}`}</button>` : '<p class="small muted2">Next round starts in a moment…</p>'}`);
if (r.outcome === 'caught' || r.outcome === 'stole') { snd.win(); burstAt(stage.querySelector('.out'), 0.5, 0.5); } else if (r.outcome === 'escaped') { snd.lose(); shake(stage.querySelector('.out')); }
if (host) on('next', next);
}
function passFinal() {
const scores = Object.fromEntries(G.players.map((p) => [p.id, p.score]));
finalScreen({ players: G.players, history: G.history, winners: C.winners(scores), daily: G.useDaily },
{ rematch: () => { G.players.forEach((p) => { p.score = 0; }); newPassGame(G.players.map((p) => p.name), G.rounds, G.packs, G.useDaily); },
edit: () => passSetup(G.players.map((p) => p.name)) });
}
function finalScreen({ players, history, winners, daily: dly }, { rematch, edit, host = true, waitingNote = '' }) {
const names = winners.map((id) => players.find((p) => p.id === id)?.name).filter(Boolean);
const line = C.emojiLine(history);
show(`${fox()}<p class="out">${names.length ? `🏆 ${esc(names.join(' & '))} ${names.length > 1 ? 'win' : 'wins'}!` : 'Nobody scored. Sneaky foxes!'}</p>
<p class="emoji" aria-label="Round results">${line}</p>
<ul class="board">${[...players].sort((a, b) => b.score - a.score).map((p) => `<li class="${winners.includes(p.id) ? 'win' : ''}">${winners.includes(p.id) ? '👑 ' : ''}${esc(p.name)}<b>${p.score}</b></li>`).join('')}</ul>
${host ? '<button class="btn go" id="again" type="button">↻ Rematch</button>' : `<p class="lead">${esc(waitingNote || 'Waiting for the host to start a rematch…')}</p>`}
<div class="row" style="justify-content:center"><button class="btn sm" id="share" type="button">📤 Share result</button><button class="btn ghost sm" id="card" type="button">🖼 Save card</button>${edit ? '<button class="btn ghost sm" id="edit" type="button">👥 Players</button>' : ''}<button class="btn ghost sm" id="menu" type="button">Menu</button></div>
<div id="fp"></div>`);
snd.win(); [0, 1, 2].forEach((i) => setTimeout(() => burstAt(stage.querySelector('.out'), 0.2 + i * 0.3, 0.5), i * 180));
const text = `Hushfox 🦊 ${history.length} rounds${dly ? ` · Daily deck #${dayNumber(today())}` : ''}\n${line}\n${names.length ? `Winner: ${names.join(' & ')}` : 'The Fox won the night'}`;
if (host) on('again', () => { count(GAME, 'play', 'rematch'); rematch(); });
on('share', async () => { const b = await canvasBlob(partyCard(players, history, names)); share(GAME, text, PATH, b, 'hushfox.png'); });
on('card', async () => { download(await canvasBlob(partyCard(players, history, names)), 'hushfox.png'); count(GAME, 'share', 'card'); });
if (edit) on('edit', edit);
on('menu', () => { leaveRoom(); home(); });
count(GAME, 'win', 'party');
pass.afterWin($('fp'), 'party');
installer.offer();
}
function drawFox(x, cx, cy, s) {
x.save(); x.translate(cx - 256 * s, cy - 256 * s); x.scale(s, s);
const P = (d, f) => { x.fillStyle = f; x.fill(new Path2D(d)); };
P('M106 132 196 226h120l90-94-26 168-124 112-124-112Z', '#FF7A2F'); P('M130 178 182 230l-30 30ZM382 178l-52 52 30 30Z', '#FFD9B8');
P('M132 300 256 412V330l-66-40ZM380 300 256 412V330l66-40Z', '#FFF3E6'); x.beginPath(); x.arc(256, 396, 19, 0, 7); x.fillStyle = '#1C1530'; x.fill();
x.strokeStyle = '#1C1530'; x.lineWidth = 15; x.lineCap = 'round'; x.stroke(new Path2D('M178 284q24 18 48 0M286 284q24 18 48 0'));
x.restore();
}
function partyCard(players, history, names) {
const top = [...players].sort((a, b) => b.score - a.score).slice(0, 4);
return scoreCard({ title: 'Hushfox', sub: `${history.length} rounds · ${players.length} players`, big: C.emojiLine(history) || '🦊', unit: names.length ? `🏆 ${names.join(' & ')}` : 'The Fox won the night',
line: 'Can your group catch the Fox?', bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', foot: 'cybermaxtools.com/games/hushfox' }, (x) => {
drawFox(x, 540, 470, 1.05);
x.font = '700 40px system-ui, sans-serif'; x.fillStyle = '#FFF3E6'; x.textAlign = 'center';
x.fillText(top.map((p) => `${p.name} ${p.score}`).join(' · '), 540, 790);
});
}
// ------------------------------------------------------------------ room-code mode
let R = null; // { code, conn, s, me, error }
function roomHome(prefill = '') {
if (!navigator.onLine) {
show(`${fox('sm')}<p class="out">You're offline</p><p class="lead">Room codes need internet. Pass-the-phone works offline!</p><button class="btn go" id="pp" type="button">📱 Pass one phone</button><button class="lnk" id="back" type="button">Back</button>`);
on('pp', () => passSetup()); on('back', home); return;
}
const me = store(LS_ME) || '';
show(`<p class="out">Room code</p><p class="lead">Each player joins on their own phone. Same room, real voices.</p>
<div class="field"><label for="me">Your name</label><input class="inp" id="me" maxlength="16" placeholder="Your name" value="${esc(me)}" autocomplete="nickname"></div>
<button class="btn go" id="create" type="button">➕ Create a room</button>
<p class="small muted2">or join one</p>
<div class="field"><input class="inp code" id="code" maxlength="4" placeholder="CODE" value="${esc(prefill)}" aria-label="Room code" autocapitalize="characters" autocomplete="off"></div>
<button class="btn ghost" id="join" type="button" style="width:min(420px,100%)">Join room</button>
<p class="small" id="err" role="alert"></p><button class="lnk" id="back" type="button">Back</button>`, 'top');
const nm = () => { const v = C.cleanName($('me').value); store(LS_ME, v); return v; };
on('create', async () => {
const b = $('create'); b.disabled = true;
try { const code = await createRoom(); count(GAME, 'play', 'room-create'); joinRoom(code, nm()); }
catch { $('err').textContent = 'Could not create a room right now. Try again, or pass one phone.'; b.disabled = false; }
});
on('join', async () => {
const code = $('code').value.trim().toUpperCase();
if (!C.validCode(code)) { $('err').textContent = 'Codes are 4 letters, like KQBX.'; shake($('code')); return; }
try {
const i = await roomInfo(code);
if (!i.open) { $('err').textContent = 'No room with that code. Check the letters.'; shake($('code')); return; }
if (i.players >= C.MAX_PLAYERS) { $('err').textContent = 'That room is full (12 players).'; return; }
} catch { $('err').textContent = 'Could not reach the room. Check your connection.'; return; }
count(GAME, 'play', 'room-join');
joinRoom(code, nm());
});
on('back', home);
if (prefill) $('me').focus();
}
function joinRoom(code, name) {
leaveRoom();
R = { code, me: pid(), s: null, peek: false, voted: null, last: '' };
history.replaceState(null, '', `${location.pathname}?room=${code}${location.search.includes('api=') ? `&api=${encodeURIComponent(new URLSearchParams(location.search).get('api'))}` : ''}`);
show(`<p class="code-big">${esc(code)}</p><p class="lead">Joining…</p>`);
R.conn = connect(code, R.me, name, {
state: (s) => { if (!R) return; R.s = s; renderRoom(); },
error: (e) => { toast(e, 3000); },
status: (st) => {
if (!R) return;
if (st === 'gone') { const c = R.code; leaveRoom(); toast('You left the room.'); roomHome(c); return; }
let n = stage.querySelector('.net');
if (st === 'retry') { if (!n) { n = document.createElement('div'); n.className = 'net'; stage.appendChild(n); } n.textContent = 'Reconnecting…'; }
else if (n) n.remove();
},
});
}
function leaveRoom() {
if (!R) return;
try { R.conn.send({ t: 'leave' }); } catch { /* */ }
setTimeout(((c) => () => c.close())(R.conn), 150);
R = null;
if (location.search.includes('room=')) history.replaceState(null, '', location.pathname);
}
const rsend = (o) => R && R.conn.send(o);
const rname = (id) => (R.s.players.find((p) => p.id === id) || {}).name || '?';
function renderRoom() {
const s = R.s, me = s.players.find((p) => p.id === R.me), host = s.host === R.me;
const key = `${s.phase}|${s.round}|${s.game}`;
const fresh = key !== R.last;
if (fresh) { R.peek = false; R.voted = null; }
R.last = key;
const local = (t) => t - (s.offset || 0); // server time -> this phone's clock
if (me && me.waiting && ['reveal', 'clue', 'vote', 'guess', 'result'].includes(s.phase)) {
show(`<p class="code-big">${esc(s.code)}</p><p class="out">You're in!</p><p class="lead">A round is on. You join from the next round.</p><p class="small muted2">${s.players.filter((p) => p.on).length} players in the room</p><button class="lnk" id="leave" type="button">Leave room</button>`);
on('leave', () => { leaveRoom(); home(); });
return;
}
if (s.phase === 'lobby') return roomLobby(s, host);
if (s.phase === 'reveal' || s.phase === 'clue') {
if (!fresh && s.phase === 'clue' && $('card')) {
// same clue phase: only the twist/peek letter may have changed
if (s.cur.twist && $('twist') && $('twist').classList.contains('wait')) flipTwist(s.cur.twist, false);
if (s.card && s.card.letter && !stage.querySelector('.letter')) $('card').insertAdjacentHTML('beforeend', `<p class="c letter">👀 First letter: <b>${esc(s.card.letter)}</b></p>`);
return;
}
const c = s.card;
show(`<p class="small muted2">Room ${esc(s.code)} · round ${s.round + 1} of ${s.rounds}</p>
${c ? `<button type="button" id="card" class="card ${c.fox ? 'foxcard' : ''} hidden" aria-label="Tap to peek at your card"><p class="k">${c.fox ? 'You are' : esc(c.cat)}</p><p class="w">${c.fox ? 'THE FOX 🦊' : esc(c.word)}</p><p class="c">${c.fox ? `Category: ${esc(c.cat)}` : 'Tap to hide'}</p>${c.letter ? `<p class="c letter">👀 First letter: <b>${esc(c.letter)}</b></p>` : ''}</button><p class="small muted2">Tap the card to peek. Keep it hidden!</p>` : ''}
${s.phase === 'reveal' ? `<p class="lead">Look at your card… clues start in a moment.</p>${timerHtml()}` : `<p class="cat">${esc(s.cur.cat)}</p><p class="lead">🎤 ${esc(rname(s.cur.starter))} starts. One clue each!</p>${timerHtml()}${twistHtml(s.cur.twist, !s.cur.twist)}`}
${host ? '<button class="btn ghost sm" id="skip" type="button">Skip ▶</button>' : ''}`);
const card = $('card');
if (card) card.addEventListener('click', () => { R.peek = !R.peek; card.classList.toggle('hidden', !R.peek); snd.tap(); buzz(15); });
if (fresh && s.phase === 'reveal') { snd.boss(); buzz(c && c.fox ? [30, 30, 30] : 30); }
if (fresh && s.phase === 'clue') snd.win();
const total = s.phase === 'reveal' ? C.REVEAL_MS : C.CLUE_MS;
runTimer(local(s.ends), total, s.phase === 'clue' ? { twistAt: local(s.twistAt), onTwist: () => {} } : {});
if (host) on('skip', () => rsend({ t: 'next' }));
return;
}
if (s.phase === 'vote') {
const voted = me && s.players.find((p) => p.id === R.me)?.voted;
const left = s.players.filter((p) => p.on && !p.waiting && !p.voted).length;
show(`<p class="out">Who's the Fox?</p><p class="cat" style="font-size:22px">${esc(s.cur.cat)}</p>${timerHtml()}
${voted ? `<p class="lead">✓ Vote in. Waiting for ${left} more…</p>` : '<p class="lead">Tap the player you think is the Fox.</p>'}
<div class="grid">${s.players.filter((p) => p.on && !p.waiting && p.id !== R.me).map((p) => `<button class="pick ${R.voted === p.id ? 'on' : ''}" type="button" data-v="${p.id}" ${voted ? 'disabled' : ''}>${esc(p.name)}${p.voted ? '<small>voted</small>' : ''}</button>`).join('')}</div>`);
if (fresh) { snd.hit(); buzz(150); }
runTimer(local(s.ends), C.VOTE_MS);
stage.querySelectorAll('[data-v]').forEach((b) => b.addEventListener('click', () => { R.voted = b.dataset.v; snd.good(3); rsend({ t: 'vote', target: b.dataset.v }); b.classList.add('on'); }));
return;
}
if (s.phase === 'guess') {
const isFox = s.cur.accused === R.me;
show(`${fox('sm')}<p class="out">${isFox ? 'You were caught!' : `${esc(rname(s.cur.accused))} is the Fox!`}</p>
${isFox ? `<p class="lead">Steal the round: pick the word.</p><div class="grid">${(s.cur.choices || []).map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>` : '<p class="lead">The Fox is trying to steal the word…</p>'}${timerHtml()}`);
if (fresh) { snd.good(8); burstAt(stage.querySelector('.fox'), 0.5, 0.5); }
runTimer(local(s.ends), C.GUESS_MS);
stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => { b.classList.add('on'); stage.querySelectorAll('[data-w]').forEach((x) => { x.disabled = true; }); rsend({ t: 'guess', word: b.dataset.w }); }));
return;
}
if (s.phase === 'result') {
if (!fresh) return;
const c = s.cur;
resultScreen({ outcome: c.outcome, word: c.word, foxName: rname(c.fox), accusedName: c.accused ? rname(c.accused) : null, deltas: c.deltas || {}, players: s.players.filter((p) => p.on), round: s.round, rounds: s.rounds },
{ next: () => rsend({ t: 'next' }), host });
return;
}
if (s.phase === 'final') {
if (!fresh && stage.querySelector('#share')) return;
const scores = Object.fromEntries(s.players.map((p) => [p.id, p.score]));
finalScreen({ players: s.players, history: s.history, winners: C.winners(scores) }, { rematch: () => rsend({ t: 'rematch' }), host });
}
}
function roomLobby(s, host) {
const link = `${SITE}${PATH}?room=${s.code}`;
const active = s.players.filter((p) => p.on);
show(`<p class="small muted2">Room code</p><p class="code-big" id="rcode">${esc(s.code)}</p>
<div class="row" style="justify-content:center"><button class="btn sm" id="invite" type="button">📨 Invite link</button><button class="btn ghost sm" id="copy" type="button">Copy code</button></div>
${s.note ? `<p class="banner">${esc(s.note)}</p>` : ''}
<ul class="board" id="plist">${active.map((p) => `<li>${p.id === s.host ? '👑 ' : ''}${esc(p.name)}${p.id === R.me ? ' (you)' : ''}${host && p.id !== R.me ? `<button class="x" type="button" data-kick="${p.id}" aria-label="Remove ${esc(p.name)}" style="margin-left:auto">×</button>` : ''}</li>`).join('')}</ul>
<p class="small muted2">${active.length}/${C.MAX_PLAYERS} players · need ${C.MIN_PLAYERS}+</p>
${host ? `<div class="field"><label>Rounds</label>${roundsHtml(s.rounds)}</div><div class="field"><label>Packs</label>${packsHtml(s.packs)}</div>
<button class="btn go" id="start" type="button" ${active.length < C.MIN_PLAYERS ? 'disabled' : ''}>▶ Start game</button>`
: `<p class="lead">Waiting for ${esc(rname(s.host))} to start…</p><p class="small muted2">Packs: ${s.packs.map((id) => esc(packById(id)?.name || id)).join(', ')} · ${s.rounds} rounds</p>`}
<button class="lnk" id="leave" type="button">Leave room</button>`, 'top');
on('invite', () => { share(GAME, `Join my Hushfox party game! Room ${s.code}`, `${PATH}?room=${s.code}`); });
on('copy', async () => { try { await navigator.clipboard.writeText(s.code); toast('Code copied'); } catch { prompt('Room code:', s.code); } });
on('leave', () => { leaveRoom(); home(); });
stage.querySelectorAll('[data-kick]').forEach((b) => b.addEventListener('click', () => rsend({ t: 'kick', target: b.dataset.kick })));
if (host) {
const passCode = () => (store('cmx-pro:fun-pass') || {}).code || '';
bindRounds((n) => rsend({ t: 'settings', rounds: n }));
const sel = s.packs.slice();
bindPacks(sel, (p) => rsend({ t: 'settings', packs: p, pass: passCode() }));
on('start', () => { rsend({ t: 'settings', packs: sel, pass: passCode(), extra: aiExtra }); setTimeout(() => rsend({ t: 'start' }), 120); });
}
void link;
}
// ------------------------------------------------------------------ solo: Fox Hunt (daily + levels)
const HUNT_DECK = deck(['classic']);
const WORLDS = ['Meadow', 'Den', 'Moonlit woods'];
const LEVELS = Array.from({ length: 30 }, (_, i) => {
const n = i + 1, sp = C.levelSpec(n);
const twist = n === 7 ? '5 words' : n === 9 ? 'Sneaky foxes from a neighbouring category' : n === 13 ? '6 words' : n === 19 ? '7 words' : n === 25 ? '8 words' : undefined;
return { n, name: `${sp.size} words${sp.hard ? ', sneaky' : ''}`, twist, boss: n % 10 === 0 };
});
const prog = progress(GAME, { isPro });
const dly = daily(GAME);
function huntHome() {
lvCss();
const done = dly.best() !== undefined;
const el = show(`<p class="out">🔎 Fox Hunt</p><p class="lead">One word in each set doesn't belong: it's the Fox. Find 5 foxes in 30 seconds.</p>
<button class="big hot" id="hd" type="button"><b>📅 Daily #${dly.num}</b><span>${done ? `Best today: ${dly.best()} · streak ${dly.streak()}` : 'Same puzzles for everyone today'}</span></button>
<button class="big" id="hl" type="button"><b>▶ Level ${prog.reached()}</b><span>${prog.cleared()}/30 levels · ${prog.totalStars()}/90 ⭐</span></button>
<section class="lvmap" aria-label="Levels"><div id="lvmap"></div></section>
<button class="lnk" id="back" type="button">Back</button>`, 'top');
on('hd', () => hunt({ daily: true }));
on('hl', () => hunt({ level: prog.reached() }));
on('back', home);
renderMap(el.querySelector('#lvmap'), prog, LEVELS, { worlds: WORLDS, onPick: (n) => hunt({ level: n }), onLocked: (n, why) => (why === 'pass' ? pass.open(`level-${n}`) : toast('Clear the level before first.')) });
}
let H = null;
function hunt({ daily: isDaily = false, level = 0, vs = null } = {}) {
const spec = isDaily ? { size: 6, hard: true, puzzles: 5, ms: C.CLUE_MS } : C.levelSpec(level);
if (level) { const lock = prog.lock(level, { challenge: !!vs }); if (lock === 'pass') { pass.open(`level-${level}`); return huntHome(); } prog.open(level); }
const set = C.huntSet(HUNT_DECK, isDaily ? `hunt-${dly.date}` : `hunt-L${level}`, spec);
H = { isDaily, level, spec, set, i: 0, marks: [], ends: Date.now() + spec.ms, vs, over: false };
count(GAME, 'play', isDaily ? 'hunt-daily' : `hunt-l${level}`);
huntPuzzle();
const step = () => {
const left = Math.max(0, H.ends - Date.now());
const bar = stage.querySelector('.hunt-bar i');
if (bar) bar.style.width = `${(left / spec.ms) * 100}%`;
if (left <= 0 && !H.over) huntEnd();
};
clearInterval(huntTick);
huntTick = setInterval(step, 150);
}
let huntTick = 0;
function huntPuzzle() {
const p = H.set[H.i];
stage.innerHTML = `<div class="scr top"><p class="small muted2">${H.isDaily ? `Daily #${dly.num}` : `Level ${H.level}`} · fox ${H.i + 1} of ${H.spec.puzzles}</p>
<div class="hunt-bar"><i style="width:100%"></i></div><div class="dots">${H.set.map((_, k) => `<i class="${H.marks[k] === true ? 'ok' : H.marks[k] === false ? 'no' : k === H.i ? 'now' : ''}"></i>`).join('')}</div>
<p class="lead">Which word is the Fox?</p><div class="grid">${p.words.map((w) => `<button class="pick" type="button" data-h="${esc(w)}">${esc(w)}</button>`).join('')}</div></div>`;
stage.querySelectorAll('[data-h]').forEach((b) => b.addEventListener('click', () => {
if (H.over || H.lock) return;
H.lock = true;
const ok = b.dataset.h === p.fox;
H.marks[H.i] = ok;
b.classList.add(ok ? 'good' : 'bad');
if (ok) { snd.good(H.i * 3); burstAt(b, 0.5, 0.5, { n: 18 }); } else { snd.lose(); shake(b); buzz(80); stage.querySelector(`[data-h="${CSS.escape(p.fox)}"]`)?.classList.add('good'); }
const note = document.createElement('p'); note.className = 'small muted2'; note.textContent = `${p.fox} is from "${p.foxCat}"; the rest are ${p.cat}.`;
stage.firstElementChild.appendChild(note);
setTimeout(() => { H.lock = false; H.i++; if (H.i >= H.spec.puzzles) huntEnd(); else if (!H.over) huntPuzzle(); }, ok ? 650 : 1300);
}));
}
function huntEnd() {
if (H.over) return;
H.over = true;
clearInterval(huntTick);
const correct = H.marks.filter(Boolean).length;
const msLeft = Math.max(0, H.ends - Date.now());
const score = C.huntScore(correct, msLeft, H.spec.puzzles);
let head = '', stars = 0, rec = null;
if (H.isDaily) { rec = dly.record(score); head = `Daily #${dly.num}`; } else { stars = starsFor(score, C.LEVEL_STARS); rec = prog.record(H.level, stars, score); head = `Level ${H.level}`; }
updateUnder();
const grid = H.set.map((_, k) => (H.marks[k] ? '🦊' : H.marks[k] === false ? '❌' : '⬜')).join('');
const vs = H.vs !== null && H.vs !== undefined ? (score > H.vs ? `You beat your friend's ${H.vs}! 🎉` : score === H.vs ? `A tie with your friend's ${H.vs}.` : `Your friend's ${H.vs} still stands. Again?`) : '';
const canNext = !H.isDaily && stars > 0 && H.level < 30;
show(`<p class="out">${head}: ${correct}/5 foxes</p>${H.isDaily ? '' : `<div class="lv-stars">${starsHtml(stars)}</div>`}
<p class="code-big" style="font-size:clamp(46px,14vw,72px);letter-spacing:0">${score}</p><p class="emoji">${grid}</p>
${vs ? `<p class="banner">${esc(vs)}</p>` : ''}${H.isDaily ? `<p class="small muted2">🔥 streak ${rec.streak} · best today ${rec.best}</p>` : ''}
<div class="row" style="justify-content:center">${canNext ? '<button class="btn" id="nx" type="button">Next level ▶</button>' : ''}<button class="btn ${canNext ? 'ghost' : ''}" id="re" type="button">↻ Again</button></div>
<div class="row" style="justify-content:center"><button class="btn sm" id="beat" type="button">🎯 Challenge a friend</button><button class="btn ghost sm" id="hcard" type="button">🖼 Card</button><button class="btn ghost sm" id="hm" type="button">Fox Hunt</button></div><div id="fp"></div>`);
if (correct >= 4) snd.win(); else snd.lose();
if (stars) [0, 1, 2].slice(0, stars).forEach((i) => setTimeout(() => snd.star(i), 200 + i * 140));
const text = `Hushfox Fox Hunt ${H.isDaily ? `#${dly.num}` : `level ${H.level}`}: ${score}\n${grid}`;
const link = challengeLink(PATH, GAME, H.isDaily ? { date: dly.date, score } : { level: H.level, score });
const card = () => scoreCard({ title: 'Hushfox', sub: H.isDaily ? `Fox Hunt daily #${dly.num}` : `Fox Hunt level ${H.level}`, big: score, unit: grid, stars: H.isDaily ? undefined : stars, bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', line: 'Can you spot the Fox faster?', foot: 'cybermaxtools.com/games/hushfox' }, (x) => drawFox(x, 540, 520, 1.1));
const again = { daily: H.isDaily, level: H.level };
on('re', () => hunt(again));
if (canNext) on('nx', () => hunt({ level: H.level + 1 }));
on('beat', async () => { const b = await canvasBlob(card()); const r = await shareChallenge(`${text}\nBeat me:`, link, b, 'hushfox-hunt.png'); if (r !== 'cancelled') count(GAME, 'share', 'challenge'); });
on('hcard', async () => { download(await canvasBlob(card()), 'hushfox-hunt.png'); count(GAME, 'share', 'card'); });
on('hm', huntHome);
count(GAME, 'win', H.isDaily ? 'hunt-daily' : 'hunt-level');
if (correct >= 3) pass.afterWin($('fp'), 'hunt');
installer.offer();
}
function updateUnder() {
const b = dly.best();
$('best').textContent = b === undefined ? '-' : b;
$('streak').textContent = dly.streak();
}
// ------------------------------------------------------------------ boot
const installer = pwa(GAME, $('install'));
pass.init({ game: GAME, name: 'Hushfox', perks: ['8 extra packs: Grown-ups night, Movie night, Office hours, Holidays, Español, Português, Deutsch, Français', 'Fox Hunt levels 21-30'],
extra: 'The Fun Pass adds 8 party packs (movies, office, holidays, grown-ups night and Spanish, Portuguese, German, French decks) and Fox Hunt levels 21-30, in every CyberMax game.' });
pass.onChange(() => { /* packs re-render on the next screen */ });
on('pass-btn', () => pass.open('button'));
themeToggle($('theme'));
const mb = $('mute');
if (mb) { mb.textContent = muted ? '🔇' : '🔊'; mb.addEventListener('click', () => { muted = !muted; store(`${GAME}:muted`, muted); mb.textContent = muted ? '🔇' : '🔊'; }); }
$('num').textContent = `#${dayNumber(today())}`;
updateUnder();
window.addEventListener('beforeunload', () => { if (R) try { R.conn.send({ t: 'leave' }); } catch { /* */ } });
(function route() {
const q = new URLSearchParams(location.search);
const room = (q.get('room') || '').toUpperCase();
const ch = readChallenge();
if (room && C.validCode(room)) {
roomHome(room);
return;
}
if (ch) {
if (ch.level) return hunt({ level: Math.min(30, ch.level), vs: ch.score });
if (ch.date === dly.date) return hunt({ daily: true, vs: ch.score });
}
home();
})();
// test + studio hooks (no effect for players)
window.__cmxTest = { C, G: () => G, R: () => R, H: () => H, prog, dly, home, passSetup, huntHome, deck };
window.__cmxShot = () => { newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 5, ['classic'], false); passClues(); flipTwist(G.cur.twist, true); muted = true; };
window.__cmxDemo = async () => { // a real round on the real screens, sped up for the demo video (~11 s)
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 3, ['classic'], false);
await wait(900); $('see').click(); await wait(1500);
const fi = G.players.findIndex((p) => p.id === G.cur.fox);
if (fi !== 0) { cardFor(fi); await wait(1500); }
passClues(); await wait(1300); flipTwist(G.cur.twist, false); await wait(1900);
passVote(); await wait(300); $('count').click(); await wait(2300);
stage.querySelector(`[data-acc="${G.cur.fox}"]`).click(); await wait(1400);
stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`).click(); await wait(1600);
};