// Hushfox core rules: pure logic shared by the browser (pass-the-phone + solo) and the room Worker (Durable Object). // No DOM, no network: everything here runs in node tests too. (c) 2026 CyberMax. All rights reserved. // // The game, in one breath: everyone sees the secret word except the Fox, who only sees the category. Each player // says one clue out loud. At the 15-second mark a Twist card flips and changes how the second lap of clues works. // After 30 seconds everyone votes for the Fox. A caught Fox can still steal the round by picking the word from six. export const MIN_PLAYERS = 3; export const MAX_PLAYERS = 12; export const CLUE_MS = 30000; export const TWIST_AT = 15000; // the Twist flips when this much of the clue time is left export const REVEAL_MS = 6000; export const VOTE_MS = 20000; export const GUESS_MS = 15000; export const RESULT_MS = 8000; export const CHOICES = 6; export const ROUND_OPTIONS = [3, 5, 8]; export const TWISTS = [ { id: 'sound', icon: '๐Ÿ”Š', text: 'Sound lap: make a noise instead of a word' }, { id: 'mime', icon: '๐Ÿคซ', text: 'Mime lap: act your clue, no talking' }, { id: 'short', icon: 'โœ‚๏ธ', text: 'One-syllable clues only' }, { id: 'point', icon: '๐Ÿ‘‰', text: 'Point at something in the room that fits' }, { id: 'color', icon: '๐ŸŽจ', text: 'Say a colour that fits the word' }, { id: 'animal', icon: '๐Ÿพ', text: 'Say an animal that fits the word' }, { id: 'whisper', icon: '๐Ÿ—ฃ๏ธ', text: 'Whisper lap: each clue quieter than the last' }, { id: 'reverse', icon: '๐Ÿ”„', text: 'Reverse: go round the other way' }, { id: 'peek', icon: '๐Ÿ‘€', text: 'Fox peek: the Fox now sees the first letter' }, { id: 'number', icon: '๐Ÿ”ข', text: 'Rate the word from 1 to 10' }, { id: 'not', icon: '๐Ÿšซ', text: 'Opposites: say what it is NOT' }, { id: 'size', icon: '๐Ÿ™Œ', text: 'Show how big it is with your hands' }, { id: 'face', icon: '๐Ÿ˜ฎ', text: 'Make the face the word would make' }, { id: 'hum', icon: '๐ŸŽต', text: 'Hum two seconds of a tune it reminds you of' }, { id: 'hot', icon: '๐Ÿ”ฅ', text: 'Hot seat: the starter explains their clue' }, { id: 'double', icon: 'โœŒ๏ธ', text: 'Two-word clues this lap' }, ]; export const twistById = (id) => TWISTS.find((t) => t.id === id) || TWISTS[0]; // ------------------------------------------------------------------ randomness (same seed = same game everywhere) export function hash(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } export function rng(seed) { let a = typeof seed === 'string' ? hash(seed) : seed >>> 0; return function () { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export const pick = (arr, r) => arr[Math.floor(r() * arr.length)]; export function shuffle(arr, r) { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } export const norm = (w) => String(w).normalize('NFD').replace(/[ฬ€-อฏ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); // ------------------------------------------------------------------ names and room codes export function cleanName(s) { const n = String(s || '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, 16); return n || 'Player'; } /** Make names unique within a room: "Sam", "Sam 2". */ export function uniqueName(name, taken) { const base = cleanName(name); const lower = new Set(taken.map((t) => t.toLowerCase())); if (!lower.has(base.toLowerCase())) return base; for (let i = 2; i < 99; i++) { const n = `${base.slice(0, 13)} ${i}`; if (!lower.has(n.toLowerCase())) return n; } return base; } export const CODE_CHARS = 'BCDFGHJKLMNPQRSTVWXZ'; // no vowels: codes never spell words; no 0/O or 1/I mix-ups export function makeCode(r = Math.random) { let c = ''; for (let i = 0; i < 4; i++) c += CODE_CHARS[Math.floor(r() * CODE_CHARS.length)]; return c; } export const validCode = (c) => typeof c === 'string' && /^[BCDFGHJKLMNPQRSTVWXZ]{4}$/.test(c); // ------------------------------------------------------------------ rounds /** * Deal one round. deck = [{cat, group, words[]}], ids = player ids in seat order, used = Set of "cat|word" already * played this game (avoided when possible), round = 0-based round number (rotates the starter). * Returns { cat, word, fox, twist, choices[6], starter, pack, lang }. */ export function deal(deck, ids, r, { used = new Set(), round = 0, twist } = {}) { if (!deck.length) throw new Error('empty deck'); if (ids.length < MIN_PLAYERS) throw new Error('not enough players'); let c, word; for (let i = 0; i < 40; i++) { c = pick(deck, r); word = pick(c.words, r); if (!used.has(`${c.cat}|${word}`)) break; } const others = shuffle(c.words.filter((w) => norm(w) !== norm(word)), r).slice(0, CHOICES - 1); const choices = shuffle([word, ...others], r); const fox = pick(ids, r); const starter = ids[(round + Math.floor(r() * ids.length)) % ids.length]; return { cat: c.cat, word, fox, twist: twist || pick(TWISTS, r).id, choices, starter, pack: c.pack || 'classic', lang: c.lang || 'en' }; } /** What one player may see: the word, or (for the Fox) only the category, plus the first letter after a peek twist. */ export function cardFor(round, id, { peek = false } = {}) { if (!round) return null; if (id === round.fox) return { fox: true, cat: round.cat, letter: peek && round.twist === 'peek' ? Array.from(round.word)[0].toUpperCase() : null }; return { fox: false, cat: round.cat, word: round.word }; } /** votes = {voter: target}. top = the single most-voted id, or null on a tie / no votes (a split table lets the Fox slip away). */ export function tally(votes) { const counts = {}; for (const t of Object.values(votes || {})) if (t) counts[t] = (counts[t] || 0) + 1; let top = null, best = 0, tie = false; for (const [id, n] of Object.entries(counts)) { if (n > best) { best = n; top = id; tie = false; } else if (n === best) tie = true; } return { counts, top: tie ? null : top, best }; } /** * Score a finished round. Outcomes: * 'escaped' the table voted someone else (or tied): Fox +2 * 'caught' the Fox was voted and guessed wrong (or not at all): every voter who named the Fox +1 * 'stole' the Fox was voted but picked the right word: Fox +2, voters who named the Fox still +1 * 'void' the Fox left mid-round: nobody scores */ export function score({ fox, word, votes = {}, guess = null, left = false }) { if (left) return { outcome: 'void', deltas: {}, accused: null }; const { top } = tally(votes); const deltas = {}; const add = (id, n) => { deltas[id] = (deltas[id] || 0) + n; }; if (top !== fox) { add(fox, 2); return { outcome: 'escaped', deltas, accused: top }; } for (const [v, t] of Object.entries(votes)) if (t === fox && v !== fox) add(v, 1); if (guess && norm(guess) === norm(word)) { add(fox, 2); return { outcome: 'stole', deltas, accused: top }; } return { outcome: 'caught', deltas, accused: top }; } export const OUTCOME_TEXT = { escaped: 'The Fox got away!', caught: 'Fox caught!', stole: 'Caught, but the Fox stole the word!', void: 'The Fox left: round off', }; export const OUTCOME_EMOJI = { escaped: '๐ŸฆŠ', caught: '๐ŸŽฏ', stole: '๐ŸŽญ', void: '๐Ÿ’จ' }; /** The emoji line for share cards: one symbol per round. */ export const emojiLine = (history) => history.map((h) => OUTCOME_EMOJI[h.outcome] || 'ยท').join(''); /** Winners = everyone on the top score. scores = {id: n}. */ export function winners(scores) { const top = Math.max(0, ...Object.values(scores)); return Object.keys(scores).filter((id) => scores[id] === top && top > 0); } // ------------------------------------------------------------------ daily deck (everyone plays the same 5 words today) export function dailyRounds(deck, iso, n = 5) { const r = rng(`hushfox-daily-${iso}`); const out = []; const seen = new Set(); while (out.length < n && seen.size < deck.length) { const c = pick(deck, r); if (seen.has(c.cat)) continue; seen.add(c.cat); out.push({ cat: c.cat, word: pick(c.words, r), twist: pick(TWISTS, r).id }); } return out; } // ------------------------------------------------------------------ solo "Fox Hunt" (levels + daily) /** Level n (1..30) settings: more words, a sneakier fox from a neighbouring category, same 30 s clock. */ export function levelSpec(n) { return { n, size: Math.min(8, 4 + Math.floor((n - 1) / 6)), hard: n > 8, puzzles: 5, ms: CLUE_MS }; } export const LEVEL_STARS = [300, 450, 560]; /** * One Fox Hunt puzzle: size-1 words from one category plus one fox word from another (a neighbouring one when hard). * The fox word never appears in the main category. Returns { cat, words[], fox, foxCat }. */ export function huntPuzzle(deck, r, { size = 6, hard = false } = {}) { for (let tries = 0; tries < 60; tries++) { const a = pick(deck, r); if (a.words.length < size) continue; const pool = deck.filter((c) => c.cat !== a.cat && (!hard || c.group === a.group)); const b = pick(pool.length ? pool : deck.filter((c) => c.cat !== a.cat), r); const inA = new Set(a.words.map(norm)); const foxes = b.words.filter((w) => !inA.has(norm(w))); if (!foxes.length) continue; const fox = pick(foxes, r); const words = shuffle([...shuffle(a.words, r).slice(0, size - 1), fox], r); return { cat: a.cat, words, fox, foxCat: b.cat }; } throw new Error('no puzzle'); } export function huntSet(deck, seed, spec) { const r = rng(seed); return Array.from({ length: spec.puzzles }, () => huntPuzzle(deck, r, spec)); } /** Fox Hunt score: 100 per fox found + 5 per second left on the clock (only when all five are found). */ export function huntScore(correct, msLeft, puzzles = 5) { return correct * 100 + (correct === puzzles ? Math.max(0, Math.round(msLeft / 1000)) * 5 : 0); } // ------------------------------------------------------------------ the room (server-authoritative state machine) /** * The room-code game as a pure reducer, so the Durable Object and the tests run the same rules. * room = newRoom(code); then room = act(room, action, now, deckFn, r). deckFn(packs, pro) returns the deck. * Phases: lobby -> reveal -> clue -> vote -> (guess) -> result -> reveal ... -> final -> (rematch) reveal. */ export function newRoom(code, now = 0) { return { code, phase: 'lobby', players: [], host: null, round: 0, rounds: 5, packs: ['classic'], pro: false, cur: null, history: [], used: [], ends: 0, twistAt: 0, created: now, updated: now, game: 0 }; } export const active = (room) => room.players.filter((p) => p.on && !p.waiting); const P = (room, id) => room.players.find((p) => p.id === id); function resetScores(room) { room.players.forEach((p) => { p.score = 0; if (p.on) p.waiting = false; }); } function startRound(room, now, deckFn, r) { const ids = active(room).map((p) => p.id); const deck = deckFn(room.packs, room.pro); const cur = deal(deck, ids, r, { used: new Set(room.used), round: room.round }); room.cur = { ...cur, votes: {}, guess: null, outcome: null, deltas: {}, accused: null }; room.used.push(`${cur.cat}|${cur.word}`); room.phase = 'reveal'; room.ends = now + REVEAL_MS; } function finishRound(room, now, { left = false } = {}) { const c = room.cur; const s = score({ fox: c.fox, word: c.word, votes: c.votes, guess: c.guess, left }); c.outcome = s.outcome; c.deltas = s.deltas; c.accused = s.accused; for (const [id, n] of Object.entries(s.deltas)) { const p = P(room, id); if (p) p.score += n; } room.history.push({ cat: c.cat, word: c.word, fox: c.fox, foxName: (P(room, c.fox) || {}).name || 'Fox', outcome: c.outcome }); room.phase = 'result'; room.ends = now + RESULT_MS; } function afterVotes(room, now) { const { top } = tally(room.cur.votes); if (top && top === room.cur.fox) { room.phase = 'guess'; room.ends = now + GUESS_MS; } else finishRound(room, now); } function toLobbyIfShort(room, now) { if (room.phase !== 'lobby' && room.phase !== 'final' && active(room).length < MIN_PLAYERS) { room.phase = 'lobby'; room.cur = null; room.ends = 0; room.note = 'Not enough players: back to the lobby'; room.players.forEach((p) => { p.waiting = false; }); room.updated = now; return true; } return false; } /** Apply one action. Returns { room, error? }. Unknown or out-of-phase actions are ignored with an error string. */ export function act(room, a, now, deckFn, r = Math.random) { room = structuredClone(room); const me = a.id ? P(room, a.id) : null; const isHost = a.id && a.id === room.host; const err = (e) => ({ room, error: e }); switch (a.t) { case 'join': { if (me) { me.on = true; if (a.name) me.name = uniqueName(a.name, room.players.filter((p) => p !== me).map((p) => p.name)); } else { if (room.players.filter((p) => p.on).length >= MAX_PLAYERS) return err('Room is full (12 players)'); const playing = room.phase !== 'lobby' && room.phase !== 'final'; room.players.push({ id: a.id, name: uniqueName(a.name, room.players.map((p) => p.name)), score: 0, on: true, waiting: playing }); } if (!room.host || !P(room, room.host)?.on) room.host = a.id; break; } case 'leave': { if (!me) return err('Not in room'); const wasFox = room.cur && room.cur.fox === a.id && ['reveal', 'clue', 'vote', 'guess'].includes(room.phase); if (room.phase === 'lobby' || room.phase === 'final' || a.hard) room.players = room.players.filter((p) => p.id !== a.id); else me.on = false; if (room.host === a.id) room.host = (room.players.find((p) => p.on) || {}).id || null; if (room.cur && room.cur.votes) { delete room.cur.votes[a.id]; for (const [v, t] of Object.entries(room.cur.votes)) if (t === a.id) delete room.cur.votes[v]; } if (toLobbyIfShort(room, now)) break; if (wasFox) finishRound(room, now, { left: true }); else if (room.phase === 'vote' && active(room).every((p) => room.cur.votes[p.id])) afterVotes(room, now); break; } case 'settings': { if (!isHost) return err('Only the host can change settings'); if (room.phase !== 'lobby' && room.phase !== 'final') return err('Game in progress'); if (ROUND_OPTIONS.includes(a.rounds)) room.rounds = a.rounds; if (Array.isArray(a.packs) && a.packs.length) room.packs = a.packs.slice(0, 9).map(String); if (typeof a.pro === 'boolean') room.pro = a.pro; break; } case 'start': case 'rematch': { if (!isHost) return err('Only the host can start'); if (a.t === 'start' && room.phase !== 'lobby') return err('Already playing'); if (a.t === 'rematch' && room.phase !== 'final') return err('Game not finished'); room.players = room.players.filter((p) => p.on); room.players.forEach((p) => { p.waiting = false; }); if (active(room).length < MIN_PLAYERS) return err(`Need at least ${MIN_PLAYERS} players`); resetScores(room); room.note = ''; room.round = 0; room.history = []; room.used = room.used.slice(-60); room.game += 1; startRound(room, now, deckFn, r); break; } case 'vote': { if (room.phase !== 'vote') return err('Not voting now'); if (!me || me.waiting || !me.on) return err('Not playing this round'); if (a.target === a.id || !active(room).some((p) => p.id === a.target)) return err('Pick another player'); room.cur.votes[a.id] = a.target; if (active(room).every((p) => room.cur.votes[p.id])) afterVotes(room, now); break; } case 'guess': { if (room.phase !== 'guess') return err('No guess now'); if (a.id !== room.cur.fox) return err('Only the Fox guesses'); room.cur.guess = String(a.word || '').slice(0, 60); finishRound(room, now); break; } case 'next': { if (!isHost) return err('Only the host can skip'); if (room.phase === 'reveal') { room.phase = 'clue'; room.ends = now + CLUE_MS; room.twistAt = room.ends - TWIST_AT; } else if (room.phase === 'clue') { room.phase = 'vote'; room.ends = now + VOTE_MS; } else if (room.phase === 'result') return tick(room, now + RESULT_MS + 1, deckFn, r, true); else return err('Nothing to skip'); break; } case 'kick': { if (!isHost || a.target === a.id) return err('Only the host can remove a player'); return act(room, { t: 'leave', id: a.target, hard: true }, now, deckFn, r); } default: return err('Unknown action'); } room.updated = now; return { room }; } /** Advance on the clock (the Durable Object alarm calls this). */ export function tick(room, now, deckFn, r = Math.random, cloned = false) { if (!cloned) room = structuredClone(room); if (!room.ends || now < room.ends) return { room }; switch (room.phase) { case 'reveal': room.phase = 'clue'; room.ends = now + CLUE_MS; room.twistAt = room.ends - TWIST_AT; break; case 'clue': room.phase = 'vote'; room.ends = now + VOTE_MS; break; case 'vote': afterVotes(room, now); break; case 'guess': finishRound(room, now); break; case 'result': room.round += 1; if (room.round >= room.rounds) { room.phase = 'final'; room.ends = 0; } else { room.players.forEach((p) => { if (p.on) p.waiting = false; }); if (!toLobbyIfShort(room, now)) startRound(room, now, deckFn, r); } break; default: room.ends = 0; } room.updated = now; return { room }; } /** The next moment the room needs the clock (phase end or the Twist flip), or 0. */ export function nextWake(room, now) { const t = [room.ends]; if (room.phase === 'clue' && room.twistAt > now) t.push(room.twistAt); const f = t.filter((x) => x && x > 0); return f.length ? Math.min(...f) : 0; } /** The state one player receives: public room + their own card. Never leaks the word to the Fox or the Fox to anyone. */ export function view(room, id, now) { const c = room.cur; const playing = c && ['reveal', 'clue', 'vote', 'guess'].includes(room.phase); const me = P(room, id); const twistOn = room.phase === 'clue' ? now >= room.twistAt : ['vote', 'guess', 'result'].includes(room.phase); const out = { code: room.code, phase: room.phase, host: room.host, you: id, round: room.round, rounds: room.rounds, packs: room.packs, pro: room.pro, ends: room.ends, twistAt: room.twistAt, now, note: room.note || '', game: room.game, players: room.players.map((p) => ({ id: p.id, name: p.name, score: p.score, on: p.on, waiting: !!p.waiting, voted: !!(c && c.votes && c.votes[p.id]) })), history: room.history.map((h) => ({ ...h })), }; if (c) { out.cur = { cat: c.cat, starter: c.starter, twist: twistOn ? c.twist : null, votes: room.phase === 'result' ? c.votes : undefined }; if (playing && me && !me.waiting) out.card = cardFor(c, id, { peek: twistOn }); if (room.phase === 'guess') { out.cur.accused = c.fox; if (id === c.fox) out.cur.choices = c.choices; } if (room.phase === 'result' || room.phase === 'final') Object.assign(out.cur, { word: c.word, fox: c.fox, guess: c.guess, outcome: c.outcome, deltas: c.deltas, accused: c.accused }); } return out; }