// 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 './looks.js'; // Theme Pack + Collector Pack (themes picked in the pack sheet), $5 Store lane 30 Oct 2026 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 = `
HUSHFOX
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
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?
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.
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!
Who got the most fingers?
${esc(nameOf(G.cur.fox))} is the Fox!
Fox, one last chance: steal the round by picking the word.
${C.OUTCOME_EMOJI[r.outcome]}
${esc(C.OUTCOME_TEXT[r.outcome])}
${sub}
The word was
${esc(r.word)}
🦊 The Fox: ${esc(r.foxName)}
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}
${esc(waitingNote || 'Waiting for the host to start a rematch…')}
`}${pass.shopButtonHtml('results')}
`); 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.
'}${isFox ? 'You were caught!' : `${esc(rname(s.cur.accused))} is the Fox!`}
${isFox ? `Steal the round: pick the word.
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 ? `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.
${H.isDaily ? `Daily #${dly.num}` : `Level ${H.level}`} · fox ${H.i + 1} of ${H.spec.puzzles}
Which word is the Fox?
${head}: ${correct}/5 foxes
${H.isDaily ? '' : `${score}
${grid}
${vs ? `` : ''}${H.isDaily ? `🔥 streak ${rec.streak} · best today ${rec.best}
` : ''}${pass.shopButtonHtml('results')}
`); 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); };