// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 = ''; 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 = `
${html}
`; 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 `
${PACKS.map((p) => { const locked = p.pass && !isPro(); const n = p.cats.reduce((a, c) => a + c[2].split('|').length, 0); return ``; }).join('')}
`; } 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 `
${C.ROUND_OPTIONS.map((r) => ``).join('')}
`; } 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 `
30
`; } /** 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 '
🃏Twist card flips at 15 seconds…
'; const t = C.twistById(id); return `
${t.icon}TWIST! ${esc(t.text)}
`; } 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()}

Everyone knows the word. Except the Fox.

`); on('h-pass', () => passSetup()); on('h-room', () => roomHome()); on('h-hunt', () => huntHome()); on('h-how', () => howTo()); } function howTo() { show(`

How to play

  1. Everyone secretly sees the word. The Fox only sees the category.
  2. Go round the table: one clue each, out loud. Don't make it too easy!
  3. At 15 seconds a Twist card flips (mime it, one syllable, opposites…). Do a second lap with the twist.
  4. At zero, count 3-2-1 and point at the Fox.
  5. Caught? The Fox can still steal the round by picking the word from six.

Fox escapes: +2 · each finger on the Fox: +1 · Fox steals the word: +2

`); 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(`

Who's playing?

${names.length < C.MAX_PLAYERS ? '' : ''}
${roundsHtml(rounds)}
${packsHtml(sel)}
`, '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(`

Round ${G.round + 1} of ${G.rounds} · card ${i + 1} of ${G.players.length}

Pass the phone to

${esc(p.name)}

Only ${esc(p.name)} looks!

`); 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 ? `

You are

THE FOX 🦊

Category: ${esc(c.cat)}

You don't know the word. Listen, blend in, give a vague clue.

` : `

${esc(c.cat)}

${esc(c.word)}

Give a clue that proves you know it, without giving it away to the Fox.

`} `); 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(`

Round ${G.round + 1} of ${G.rounds}

${esc(G.cur.cat)}

🎤 ${esc(nameOf(G.cur.starter))} starts. One clue each, go round!

${timerHtml()}${twistHtml(G.cur.twist, true)} `); 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(`

Where's the Fox?

Count 3-2-1 and everyone points at once!

`); 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')}

${esc(nameOf(G.cur.fox))} is the Fox!

Fox, one last chance: steal the round by picking the word.

${G.cur.choices.map((w) => ``).join('')}
`); 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(`

${C.OUTCOME_EMOJI[r.outcome]}

${esc(C.OUTCOME_TEXT[r.outcome])}

${sub}

The word was

${esc(r.word)}

🦊 The Fox: ${esc(r.foxName)}

${host ? `` : '

Next round starts in a moment…

'}`); 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()}

${names.length ? `🏆 ${esc(names.join(' & '))} ${names.length > 1 ? 'win' : 'wins'}!` : 'Nobody scored. Sneaky foxes!'}

${line}

${host ? '' : `

${esc(waitingNote || 'Waiting for the host to start a rematch…')}

`}
${edit ? '' : ''}
`); 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')}

You're offline

Room codes need internet. Pass-the-phone works offline!

`); on('pp', () => passSetup()); on('back', home); return; } const me = store(LS_ME) || ''; show(`

Room code

Each player joins on their own phone. Same room, real voices.

or join one

`, '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(`

${esc(code)}

Joining…

`); 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(`

${esc(s.code)}

You're in!

A round is on. You join from the next round.

${s.players.filter((p) => p.on).length} players in the room

`); 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', `

👀 First letter: ${esc(s.card.letter)}

`); return; } const c = s.card; show(`

Room ${esc(s.code)} · round ${s.round + 1} of ${s.rounds}

${c ? `

Tap the card to peek. Keep it hidden!

` : ''} ${s.phase === 'reveal' ? `

Look at your card… clues start in a moment.

${timerHtml()}` : `

${esc(s.cur.cat)}

🎤 ${esc(rname(s.cur.starter))} starts. One clue each!

${timerHtml()}${twistHtml(s.cur.twist, !s.cur.twist)}`} ${host ? '' : ''}`); 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(`

Who's the Fox?

${esc(s.cur.cat)}

${timerHtml()} ${voted ? `

✓ Vote in. Waiting for ${left} more…

` : '

Tap the player you think is the Fox.

'}
${s.players.filter((p) => p.on && !p.waiting && p.id !== R.me).map((p) => ``).join('')}
`); 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')}

${isFox ? 'You were caught!' : `${esc(rname(s.cur.accused))} is the Fox!`}

${isFox ? `

Steal the round: pick the word.

${(s.cur.choices || []).map((w) => ``).join('')}
` : '

The Fox is trying to steal the word…

'}${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(`

Room code

${esc(s.code)}

${s.note ? `` : ''}

${active.length}/${C.MAX_PLAYERS} players · need ${C.MIN_PLAYERS}+

${host ? `
${roundsHtml(s.rounds)}
${packsHtml(s.packs)}
` : `

Waiting for ${esc(rname(s.host))} to start…

Packs: ${s.packs.map((id) => esc(packById(id)?.name || id)).join(', ')} · ${s.rounds} rounds

`} `, '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(`

🔎 Fox Hunt

One word in each set doesn't belong: it's the Fox. Find 5 foxes in 30 seconds.

`, '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 = `

${H.isDaily ? `Daily #${dly.num}` : `Level ${H.level}`} · fox ${H.i + 1} of ${H.spec.puzzles}

${H.set.map((_, k) => ``).join('')}

Which word is the Fox?

${p.words.map((w) => ``).join('')}
`; 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(`

${head}: ${correct}/5 foxes

${H.isDaily ? '' : `
${starsHtml(stars)}
`}

${score}

${grid}

${vs ? `` : ''}${H.isDaily ? `

🔥 streak ${rec.streak} · best today ${rec.best}

` : ''}
${canNext ? '' : ''}
`); 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); };