Spaces:
Running
Running
Deploy hushfox to the hub
Browse files- .gitattributes +2 -0
- hushfox/cmx.js +181 -0
- hushfox/core.js +400 -0
- hushfox/fun.css +54 -0
- hushfox/funpass.js +227 -0
- hushfox/game.js +661 -0
- hushfox/icon-192.png +0 -0
- hushfox/icon-512.png +0 -0
- hushfox/icon-maskable.png +0 -0
- hushfox/icon.svg +1 -0
- hushfox/index.html +64 -0
- hushfox/levels.js +341 -0
- hushfox/manifest.webmanifest +47 -0
- hushfox/net.js +55 -0
- hushfox/offer.js +2 -0
- hushfox/og.png +3 -0
- hushfox/packs.js +151 -0
- hushfox/safety.js +59 -0
- hushfox/shot-phone.png +3 -0
- hushfox/style.css +76 -0
- hushfox/sw.js +25 -0
- hushfox/words.js +151 -0
.gitattributes
CHANGED
|
@@ -1081,3 +1081,5 @@ icons/tariff-api.png filter=lfs diff=lfs merge=lfs -text
|
|
| 1081 |
icons/wedwren.png filter=lfs diff=lfs merge=lfs -text
|
| 1082 |
icons/shipfern.png filter=lfs diff=lfs merge=lfs -text
|
| 1083 |
icons/tillwren.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 1081 |
icons/wedwren.png filter=lfs diff=lfs merge=lfs -text
|
| 1082 |
icons/shipfern.png filter=lfs diff=lfs merge=lfs -text
|
| 1083 |
icons/tillwren.png filter=lfs diff=lfs merge=lfs -text
|
| 1084 |
+
hushfox/og.png filter=lfs diff=lfs merge=lfs -text
|
| 1085 |
+
hushfox/shot-phone.png filter=lfs diff=lfs merge=lfs -text
|
hushfox/cmx.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by
|
| 2 |
+
// scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own).
|
| 3 |
+
// Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install.
|
| 4 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 5 |
+
|
| 6 |
+
export const SITE = 'https://cybermaxtools.com';
|
| 7 |
+
export const API = 'https://toys.cybermaxtools.com';
|
| 8 |
+
export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game
|
| 9 |
+
|
| 10 |
+
/** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */
|
| 11 |
+
export function today(d = new Date()) {
|
| 12 |
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
| 13 |
+
}
|
| 14 |
+
/** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */
|
| 15 |
+
export function dayNumber(iso) {
|
| 16 |
+
const [y, m, d] = iso.split('-').map(Number);
|
| 17 |
+
return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1;
|
| 18 |
+
}
|
| 19 |
+
export function dateOf(n) {
|
| 20 |
+
const t = new Date(EPOCH + (n - 1) * 864e5);
|
| 21 |
+
return t.toISOString().slice(0, 10);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */
|
| 25 |
+
export function hash(str) {
|
| 26 |
+
let h = 0x811c9dc5;
|
| 27 |
+
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
| 28 |
+
return h >>> 0;
|
| 29 |
+
}
|
| 30 |
+
export function rng(seed) {
|
| 31 |
+
let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
|
| 32 |
+
return function () {
|
| 33 |
+
a = (a + 0x6d2b79f5) >>> 0;
|
| 34 |
+
let t = a;
|
| 35 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 36 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 37 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
/** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */
|
| 42 |
+
export function count(game, ev, ch = '') {
|
| 43 |
+
try {
|
| 44 |
+
if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return;
|
| 45 |
+
const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`;
|
| 46 |
+
if (navigator.sendBeacon && navigator.sendBeacon(u)) return;
|
| 47 |
+
fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {});
|
| 48 |
+
} catch { /* never break the game */ }
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function store(key, val) {
|
| 52 |
+
try {
|
| 53 |
+
if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
|
| 54 |
+
localStorage.setItem(key, JSON.stringify(val));
|
| 55 |
+
} catch { /* private mode */ }
|
| 56 |
+
return val;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export function toast(msg, ms = 2400) {
|
| 60 |
+
let t = document.getElementById('toast');
|
| 61 |
+
if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); }
|
| 62 |
+
t.textContent = msg;
|
| 63 |
+
t.classList.add('on');
|
| 64 |
+
clearTimeout(t._h);
|
| 65 |
+
t._h = setTimeout(() => t.classList.remove('on'), ms);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/** The link people share: the canonical game page with UTM tags the site view counter reads. */
|
| 69 |
+
export function shareLink(path, channel, game) {
|
| 70 |
+
const u = new URL(SITE + path);
|
| 71 |
+
u.searchParams.set('utm_source', channel);
|
| 72 |
+
u.searchParams.set('utm_medium', 'share');
|
| 73 |
+
u.searchParams.set('utm_campaign', `game-${game}`);
|
| 74 |
+
return u.toString();
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard.
|
| 79 |
+
* text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card.
|
| 80 |
+
*/
|
| 81 |
+
export async function share(game, text, path, blob, filename) {
|
| 82 |
+
const url = shareLink(path, 'share', game);
|
| 83 |
+
const full = `${text}\n${url}`;
|
| 84 |
+
if (navigator.share) {
|
| 85 |
+
try {
|
| 86 |
+
const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null;
|
| 87 |
+
const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full };
|
| 88 |
+
await navigator.share(data);
|
| 89 |
+
count(game, 'share', 'native');
|
| 90 |
+
return 'shared';
|
| 91 |
+
} catch (e) {
|
| 92 |
+
if (e && e.name === 'AbortError') return 'cancelled';
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); }
|
| 96 |
+
count(game, 'share', 'copy');
|
| 97 |
+
return 'copied';
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
export function intents(game, text, path) {
|
| 101 |
+
const u = (ch) => shareLink(path, ch, game);
|
| 102 |
+
return {
|
| 103 |
+
x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`,
|
| 104 |
+
bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`,
|
| 105 |
+
whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`,
|
| 106 |
+
};
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export function download(blob, filename) {
|
| 110 |
+
const a = document.createElement('a');
|
| 111 |
+
a.href = URL.createObjectURL(blob);
|
| 112 |
+
a.download = filename;
|
| 113 |
+
document.body.appendChild(a);
|
| 114 |
+
a.click();
|
| 115 |
+
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button.
|
| 120 |
+
* Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint.
|
| 121 |
+
* The button only appears after the first finished round (offer(): never on first load) and never once installed.
|
| 122 |
+
*/
|
| 123 |
+
export function pwa(game, btn) {
|
| 124 |
+
if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) {
|
| 125 |
+
navigator.serviceWorker.register('sw.js').catch(() => {});
|
| 126 |
+
}
|
| 127 |
+
const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
|
| 128 |
+
if (standalone) { count(game, 'play', 'app'); return { offer() {} }; }
|
| 129 |
+
let deferred = null;
|
| 130 |
+
let wanted = false;
|
| 131 |
+
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
|
| 132 |
+
window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn && !store(`cmx-install-no:${game}`)) btn.hidden = false; });
|
| 133 |
+
window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
|
| 134 |
+
if (btn) {
|
| 135 |
+
btn.addEventListener('click', async () => {
|
| 136 |
+
if (deferred) {
|
| 137 |
+
deferred.prompt();
|
| 138 |
+
const r = await deferred.userChoice.catch(() => null);
|
| 139 |
+
if (r && r.outcome === 'accepted') btn.hidden = true;
|
| 140 |
+
else if (r && r.outcome === 'dismissed') { store(`cmx-install-no:${game}`, 1); btn.hidden = true; } // "Not now" is remembered
|
| 141 |
+
deferred = null;
|
| 142 |
+
} else if (ios) {
|
| 143 |
+
toast('Tap the Share button, then "Add to Home Screen".', 5000);
|
| 144 |
+
count(game, 'install', 'ios-hint');
|
| 145 |
+
store(`cmx-install-no:${game}`, 1);
|
| 146 |
+
}
|
| 147 |
+
});
|
| 148 |
+
}
|
| 149 |
+
return {
|
| 150 |
+
offer() {
|
| 151 |
+
wanted = true;
|
| 152 |
+
if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
|
| 153 |
+
},
|
| 154 |
+
};
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/** Rounded rectangle path for canvas cards. */
|
| 158 |
+
export function rr(ctx, x, y, w, h, r) {
|
| 159 |
+
ctx.beginPath();
|
| 160 |
+
ctx.moveTo(x + r, y);
|
| 161 |
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
| 162 |
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
| 163 |
+
ctx.arcTo(x, y + h, x, y, r);
|
| 164 |
+
ctx.arcTo(x, y, x + w, y, r);
|
| 165 |
+
ctx.closePath();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
|
| 169 |
+
|
| 170 |
+
/** Theme toggle shared by every page (light/dark, remembered). */
|
| 171 |
+
export function themeToggle(btn) {
|
| 172 |
+
if (!btn) return;
|
| 173 |
+
btn.addEventListener('click', () => {
|
| 174 |
+
const r = document.documentElement;
|
| 175 |
+
const c = r.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
| 176 |
+
const n = c === 'dark' ? 'light' : 'dark';
|
| 177 |
+
r.dataset.theme = n;
|
| 178 |
+
store('cm-theme-v', n);
|
| 179 |
+
try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
|
| 180 |
+
});
|
| 181 |
+
}
|
hushfox/core.js
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hushfox core rules: pure logic shared by the browser (pass-the-phone + solo) and the room Worker (Durable Object).
|
| 2 |
+
// No DOM, no network: everything here runs in node tests too. (c) 2026 CyberMax. All rights reserved.
|
| 3 |
+
//
|
| 4 |
+
// The game, in one breath: everyone sees the secret word except the Fox, who only sees the category. Each player
|
| 5 |
+
// says one clue out loud. At the 15-second mark a Twist card flips and changes how the second lap of clues works.
|
| 6 |
+
// After 30 seconds everyone votes for the Fox. A caught Fox can still steal the round by picking the word from six.
|
| 7 |
+
|
| 8 |
+
export const MIN_PLAYERS = 3;
|
| 9 |
+
export const MAX_PLAYERS = 12;
|
| 10 |
+
export const CLUE_MS = 30000;
|
| 11 |
+
export const TWIST_AT = 15000; // the Twist flips when this much of the clue time is left
|
| 12 |
+
export const REVEAL_MS = 6000;
|
| 13 |
+
export const VOTE_MS = 20000;
|
| 14 |
+
export const GUESS_MS = 15000;
|
| 15 |
+
export const RESULT_MS = 8000;
|
| 16 |
+
export const CHOICES = 6;
|
| 17 |
+
export const ROUND_OPTIONS = [3, 5, 8];
|
| 18 |
+
|
| 19 |
+
export const TWISTS = [
|
| 20 |
+
{ id: 'sound', icon: '🔊', text: 'Sound lap: make a noise instead of a word' },
|
| 21 |
+
{ id: 'mime', icon: '🤫', text: 'Mime lap: act your clue, no talking' },
|
| 22 |
+
{ id: 'short', icon: '✂️', text: 'One-syllable clues only' },
|
| 23 |
+
{ id: 'point', icon: '👉', text: 'Point at something in the room that fits' },
|
| 24 |
+
{ id: 'color', icon: '🎨', text: 'Say a colour that fits the word' },
|
| 25 |
+
{ id: 'animal', icon: '🐾', text: 'Say an animal that fits the word' },
|
| 26 |
+
{ id: 'whisper', icon: '🗣️', text: 'Whisper lap: each clue quieter than the last' },
|
| 27 |
+
{ id: 'reverse', icon: '🔄', text: 'Reverse: go round the other way' },
|
| 28 |
+
{ id: 'peek', icon: '👀', text: 'Fox peek: the Fox now sees the first letter' },
|
| 29 |
+
{ id: 'number', icon: '🔢', text: 'Rate the word from 1 to 10' },
|
| 30 |
+
{ id: 'not', icon: '🚫', text: 'Opposites: say what it is NOT' },
|
| 31 |
+
{ id: 'size', icon: '🙌', text: 'Show how big it is with your hands' },
|
| 32 |
+
{ id: 'face', icon: '😮', text: 'Make the face the word would make' },
|
| 33 |
+
{ id: 'hum', icon: '🎵', text: 'Hum two seconds of a tune it reminds you of' },
|
| 34 |
+
{ id: 'hot', icon: '🔥', text: 'Hot seat: the starter explains their clue' },
|
| 35 |
+
{ id: 'double', icon: '✌️', text: 'Two-word clues this lap' },
|
| 36 |
+
];
|
| 37 |
+
export const twistById = (id) => TWISTS.find((t) => t.id === id) || TWISTS[0];
|
| 38 |
+
|
| 39 |
+
// ------------------------------------------------------------------ randomness (same seed = same game everywhere)
|
| 40 |
+
|
| 41 |
+
export function hash(str) {
|
| 42 |
+
let h = 0x811c9dc5;
|
| 43 |
+
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
|
| 44 |
+
return h >>> 0;
|
| 45 |
+
}
|
| 46 |
+
export function rng(seed) {
|
| 47 |
+
let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
|
| 48 |
+
return function () {
|
| 49 |
+
a = (a + 0x6d2b79f5) >>> 0;
|
| 50 |
+
let t = a;
|
| 51 |
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
| 52 |
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
| 53 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
| 54 |
+
};
|
| 55 |
+
}
|
| 56 |
+
export const pick = (arr, r) => arr[Math.floor(r() * arr.length)];
|
| 57 |
+
export function shuffle(arr, r) {
|
| 58 |
+
const a = arr.slice();
|
| 59 |
+
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
|
| 60 |
+
return a;
|
| 61 |
+
}
|
| 62 |
+
export const norm = (w) => String(w).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
| 63 |
+
|
| 64 |
+
// ------------------------------------------------------------------ names and room codes
|
| 65 |
+
|
| 66 |
+
export function cleanName(s) {
|
| 67 |
+
const n = String(s || '').replace(/[\u0000-\u001f<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, 16);
|
| 68 |
+
return n || 'Player';
|
| 69 |
+
}
|
| 70 |
+
/** Make names unique within a room: "Sam", "Sam 2". */
|
| 71 |
+
export function uniqueName(name, taken) {
|
| 72 |
+
const base = cleanName(name);
|
| 73 |
+
const lower = new Set(taken.map((t) => t.toLowerCase()));
|
| 74 |
+
if (!lower.has(base.toLowerCase())) return base;
|
| 75 |
+
for (let i = 2; i < 99; i++) { const n = `${base.slice(0, 13)} ${i}`; if (!lower.has(n.toLowerCase())) return n; }
|
| 76 |
+
return base;
|
| 77 |
+
}
|
| 78 |
+
export const CODE_CHARS = 'BCDFGHJKLMNPQRSTVWXZ'; // no vowels: codes never spell words; no 0/O or 1/I mix-ups
|
| 79 |
+
export function makeCode(r = Math.random) {
|
| 80 |
+
let c = '';
|
| 81 |
+
for (let i = 0; i < 4; i++) c += CODE_CHARS[Math.floor(r() * CODE_CHARS.length)];
|
| 82 |
+
return c;
|
| 83 |
+
}
|
| 84 |
+
export const validCode = (c) => typeof c === 'string' && /^[BCDFGHJKLMNPQRSTVWXZ]{4}$/.test(c);
|
| 85 |
+
|
| 86 |
+
// ------------------------------------------------------------------ rounds
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* Deal one round. deck = [{cat, group, words[]}], ids = player ids in seat order, used = Set of "cat|word" already
|
| 90 |
+
* played this game (avoided when possible), round = 0-based round number (rotates the starter).
|
| 91 |
+
* Returns { cat, word, fox, twist, choices[6], starter, pack, lang }.
|
| 92 |
+
*/
|
| 93 |
+
export function deal(deck, ids, r, { used = new Set(), round = 0, twist } = {}) {
|
| 94 |
+
if (!deck.length) throw new Error('empty deck');
|
| 95 |
+
if (ids.length < MIN_PLAYERS) throw new Error('not enough players');
|
| 96 |
+
let c, word;
|
| 97 |
+
for (let i = 0; i < 40; i++) {
|
| 98 |
+
c = pick(deck, r);
|
| 99 |
+
word = pick(c.words, r);
|
| 100 |
+
if (!used.has(`${c.cat}|${word}`)) break;
|
| 101 |
+
}
|
| 102 |
+
const others = shuffle(c.words.filter((w) => norm(w) !== norm(word)), r).slice(0, CHOICES - 1);
|
| 103 |
+
const choices = shuffle([word, ...others], r);
|
| 104 |
+
const fox = pick(ids, r);
|
| 105 |
+
const starter = ids[(round + Math.floor(r() * ids.length)) % ids.length];
|
| 106 |
+
return { cat: c.cat, word, fox, twist: twist || pick(TWISTS, r).id, choices, starter, pack: c.pack || 'classic', lang: c.lang || 'en' };
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/** What one player may see: the word, or (for the Fox) only the category, plus the first letter after a peek twist. */
|
| 110 |
+
export function cardFor(round, id, { peek = false } = {}) {
|
| 111 |
+
if (!round) return null;
|
| 112 |
+
if (id === round.fox) return { fox: true, cat: round.cat, letter: peek && round.twist === 'peek' ? Array.from(round.word)[0].toUpperCase() : null };
|
| 113 |
+
return { fox: false, cat: round.cat, word: round.word };
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
/** votes = {voter: target}. top = the single most-voted id, or null on a tie / no votes (a split table lets the Fox slip away). */
|
| 117 |
+
export function tally(votes) {
|
| 118 |
+
const counts = {};
|
| 119 |
+
for (const t of Object.values(votes || {})) if (t) counts[t] = (counts[t] || 0) + 1;
|
| 120 |
+
let top = null, best = 0, tie = false;
|
| 121 |
+
for (const [id, n] of Object.entries(counts)) {
|
| 122 |
+
if (n > best) { best = n; top = id; tie = false; } else if (n === best) tie = true;
|
| 123 |
+
}
|
| 124 |
+
return { counts, top: tie ? null : top, best };
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* Score a finished round. Outcomes:
|
| 129 |
+
* 'escaped' the table voted someone else (or tied): Fox +2
|
| 130 |
+
* 'caught' the Fox was voted and guessed wrong (or not at all): every voter who named the Fox +1
|
| 131 |
+
* 'stole' the Fox was voted but picked the right word: Fox +2, voters who named the Fox still +1
|
| 132 |
+
* 'void' the Fox left mid-round: nobody scores
|
| 133 |
+
*/
|
| 134 |
+
export function score({ fox, word, votes = {}, guess = null, left = false }) {
|
| 135 |
+
if (left) return { outcome: 'void', deltas: {}, accused: null };
|
| 136 |
+
const { top } = tally(votes);
|
| 137 |
+
const deltas = {};
|
| 138 |
+
const add = (id, n) => { deltas[id] = (deltas[id] || 0) + n; };
|
| 139 |
+
if (top !== fox) { add(fox, 2); return { outcome: 'escaped', deltas, accused: top }; }
|
| 140 |
+
for (const [v, t] of Object.entries(votes)) if (t === fox && v !== fox) add(v, 1);
|
| 141 |
+
if (guess && norm(guess) === norm(word)) { add(fox, 2); return { outcome: 'stole', deltas, accused: top }; }
|
| 142 |
+
return { outcome: 'caught', deltas, accused: top };
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
export const OUTCOME_TEXT = {
|
| 146 |
+
escaped: 'The Fox got away!',
|
| 147 |
+
caught: 'Fox caught!',
|
| 148 |
+
stole: 'Caught, but the Fox stole the word!',
|
| 149 |
+
void: 'The Fox left: round off',
|
| 150 |
+
};
|
| 151 |
+
export const OUTCOME_EMOJI = { escaped: '🦊', caught: '🎯', stole: '🎭', void: '💨' };
|
| 152 |
+
|
| 153 |
+
/** The emoji line for share cards: one symbol per round. */
|
| 154 |
+
export const emojiLine = (history) => history.map((h) => OUTCOME_EMOJI[h.outcome] || '·').join('');
|
| 155 |
+
|
| 156 |
+
/** Winners = everyone on the top score. scores = {id: n}. */
|
| 157 |
+
export function winners(scores) {
|
| 158 |
+
const top = Math.max(0, ...Object.values(scores));
|
| 159 |
+
return Object.keys(scores).filter((id) => scores[id] === top && top > 0);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// ------------------------------------------------------------------ daily deck (everyone plays the same 5 words today)
|
| 163 |
+
|
| 164 |
+
export function dailyRounds(deck, iso, n = 5) {
|
| 165 |
+
const r = rng(`hushfox-daily-${iso}`);
|
| 166 |
+
const out = [];
|
| 167 |
+
const seen = new Set();
|
| 168 |
+
while (out.length < n && seen.size < deck.length) {
|
| 169 |
+
const c = pick(deck, r);
|
| 170 |
+
if (seen.has(c.cat)) continue;
|
| 171 |
+
seen.add(c.cat);
|
| 172 |
+
out.push({ cat: c.cat, word: pick(c.words, r), twist: pick(TWISTS, r).id });
|
| 173 |
+
}
|
| 174 |
+
return out;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
// ------------------------------------------------------------------ solo "Fox Hunt" (levels + daily)
|
| 178 |
+
|
| 179 |
+
/** Level n (1..30) settings: more words, a sneakier fox from a neighbouring category, same 30 s clock. */
|
| 180 |
+
export function levelSpec(n) {
|
| 181 |
+
return { n, size: Math.min(8, 4 + Math.floor((n - 1) / 6)), hard: n > 8, puzzles: 5, ms: CLUE_MS };
|
| 182 |
+
}
|
| 183 |
+
export const LEVEL_STARS = [300, 450, 560];
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* One Fox Hunt puzzle: size-1 words from one category plus one fox word from another (a neighbouring one when hard).
|
| 187 |
+
* The fox word never appears in the main category. Returns { cat, words[], fox, foxCat }.
|
| 188 |
+
*/
|
| 189 |
+
export function huntPuzzle(deck, r, { size = 6, hard = false } = {}) {
|
| 190 |
+
for (let tries = 0; tries < 60; tries++) {
|
| 191 |
+
const a = pick(deck, r);
|
| 192 |
+
if (a.words.length < size) continue;
|
| 193 |
+
const pool = deck.filter((c) => c.cat !== a.cat && (!hard || c.group === a.group));
|
| 194 |
+
const b = pick(pool.length ? pool : deck.filter((c) => c.cat !== a.cat), r);
|
| 195 |
+
const inA = new Set(a.words.map(norm));
|
| 196 |
+
const foxes = b.words.filter((w) => !inA.has(norm(w)));
|
| 197 |
+
if (!foxes.length) continue;
|
| 198 |
+
const fox = pick(foxes, r);
|
| 199 |
+
const words = shuffle([...shuffle(a.words, r).slice(0, size - 1), fox], r);
|
| 200 |
+
return { cat: a.cat, words, fox, foxCat: b.cat };
|
| 201 |
+
}
|
| 202 |
+
throw new Error('no puzzle');
|
| 203 |
+
}
|
| 204 |
+
export function huntSet(deck, seed, spec) {
|
| 205 |
+
const r = rng(seed);
|
| 206 |
+
return Array.from({ length: spec.puzzles }, () => huntPuzzle(deck, r, spec));
|
| 207 |
+
}
|
| 208 |
+
/** Fox Hunt score: 100 per fox found + 5 per second left on the clock (only when all five are found). */
|
| 209 |
+
export function huntScore(correct, msLeft, puzzles = 5) {
|
| 210 |
+
return correct * 100 + (correct === puzzles ? Math.max(0, Math.round(msLeft / 1000)) * 5 : 0);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// ------------------------------------------------------------------ the room (server-authoritative state machine)
|
| 214 |
+
|
| 215 |
+
/**
|
| 216 |
+
* The room-code game as a pure reducer, so the Durable Object and the tests run the same rules.
|
| 217 |
+
* room = newRoom(code); then room = act(room, action, now, deckFn, r). deckFn(packs, pro) returns the deck.
|
| 218 |
+
* Phases: lobby -> reveal -> clue -> vote -> (guess) -> result -> reveal ... -> final -> (rematch) reveal.
|
| 219 |
+
*/
|
| 220 |
+
export function newRoom(code, now = 0) {
|
| 221 |
+
return { code, phase: 'lobby', players: [], host: null, round: 0, rounds: 5, packs: ['classic'], pro: false,
|
| 222 |
+
cur: null, history: [], used: [], ends: 0, twistAt: 0, created: now, updated: now, game: 0 };
|
| 223 |
+
}
|
| 224 |
+
export const active = (room) => room.players.filter((p) => p.on && !p.waiting);
|
| 225 |
+
const P = (room, id) => room.players.find((p) => p.id === id);
|
| 226 |
+
|
| 227 |
+
function resetScores(room) { room.players.forEach((p) => { p.score = 0; if (p.on) p.waiting = false; }); }
|
| 228 |
+
|
| 229 |
+
function startRound(room, now, deckFn, r) {
|
| 230 |
+
const ids = active(room).map((p) => p.id);
|
| 231 |
+
const deck = deckFn(room.packs, room.pro);
|
| 232 |
+
const cur = deal(deck, ids, r, { used: new Set(room.used), round: room.round });
|
| 233 |
+
room.cur = { ...cur, votes: {}, guess: null, outcome: null, deltas: {}, accused: null };
|
| 234 |
+
room.used.push(`${cur.cat}|${cur.word}`);
|
| 235 |
+
room.phase = 'reveal';
|
| 236 |
+
room.ends = now + REVEAL_MS;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
function finishRound(room, now, { left = false } = {}) {
|
| 240 |
+
const c = room.cur;
|
| 241 |
+
const s = score({ fox: c.fox, word: c.word, votes: c.votes, guess: c.guess, left });
|
| 242 |
+
c.outcome = s.outcome; c.deltas = s.deltas; c.accused = s.accused;
|
| 243 |
+
for (const [id, n] of Object.entries(s.deltas)) { const p = P(room, id); if (p) p.score += n; }
|
| 244 |
+
room.history.push({ cat: c.cat, word: c.word, fox: c.fox, foxName: (P(room, c.fox) || {}).name || 'Fox', outcome: c.outcome });
|
| 245 |
+
room.phase = 'result';
|
| 246 |
+
room.ends = now + RESULT_MS;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function afterVotes(room, now) {
|
| 250 |
+
const { top } = tally(room.cur.votes);
|
| 251 |
+
if (top && top === room.cur.fox) { room.phase = 'guess'; room.ends = now + GUESS_MS; }
|
| 252 |
+
else finishRound(room, now);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function toLobbyIfShort(room, now) {
|
| 256 |
+
if (room.phase !== 'lobby' && room.phase !== 'final' && active(room).length < MIN_PLAYERS) {
|
| 257 |
+
room.phase = 'lobby'; room.cur = null; room.ends = 0; room.note = 'Not enough players: back to the lobby';
|
| 258 |
+
room.players.forEach((p) => { p.waiting = false; });
|
| 259 |
+
room.updated = now;
|
| 260 |
+
return true;
|
| 261 |
+
}
|
| 262 |
+
return false;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/** Apply one action. Returns { room, error? }. Unknown or out-of-phase actions are ignored with an error string. */
|
| 266 |
+
export function act(room, a, now, deckFn, r = Math.random) {
|
| 267 |
+
room = structuredClone(room);
|
| 268 |
+
const me = a.id ? P(room, a.id) : null;
|
| 269 |
+
const isHost = a.id && a.id === room.host;
|
| 270 |
+
const err = (e) => ({ room, error: e });
|
| 271 |
+
switch (a.t) {
|
| 272 |
+
case 'join': {
|
| 273 |
+
if (me) { me.on = true; if (a.name) me.name = uniqueName(a.name, room.players.filter((p) => p !== me).map((p) => p.name)); }
|
| 274 |
+
else {
|
| 275 |
+
if (room.players.filter((p) => p.on).length >= MAX_PLAYERS) return err('Room is full (12 players)');
|
| 276 |
+
const playing = room.phase !== 'lobby' && room.phase !== 'final';
|
| 277 |
+
room.players.push({ id: a.id, name: uniqueName(a.name, room.players.map((p) => p.name)), score: 0, on: true, waiting: playing });
|
| 278 |
+
}
|
| 279 |
+
if (!room.host || !P(room, room.host)?.on) room.host = a.id;
|
| 280 |
+
break;
|
| 281 |
+
}
|
| 282 |
+
case 'leave': {
|
| 283 |
+
if (!me) return err('Not in room');
|
| 284 |
+
const wasFox = room.cur && room.cur.fox === a.id && ['reveal', 'clue', 'vote', 'guess'].includes(room.phase);
|
| 285 |
+
if (room.phase === 'lobby' || room.phase === 'final' || a.hard) room.players = room.players.filter((p) => p.id !== a.id);
|
| 286 |
+
else me.on = false;
|
| 287 |
+
if (room.host === a.id) room.host = (room.players.find((p) => p.on) || {}).id || null;
|
| 288 |
+
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]; }
|
| 289 |
+
if (toLobbyIfShort(room, now)) break;
|
| 290 |
+
if (wasFox) finishRound(room, now, { left: true });
|
| 291 |
+
else if (room.phase === 'vote' && active(room).every((p) => room.cur.votes[p.id])) afterVotes(room, now);
|
| 292 |
+
break;
|
| 293 |
+
}
|
| 294 |
+
case 'settings': {
|
| 295 |
+
if (!isHost) return err('Only the host can change settings');
|
| 296 |
+
if (room.phase !== 'lobby' && room.phase !== 'final') return err('Game in progress');
|
| 297 |
+
if (ROUND_OPTIONS.includes(a.rounds)) room.rounds = a.rounds;
|
| 298 |
+
if (Array.isArray(a.packs) && a.packs.length) room.packs = a.packs.slice(0, 9).map(String);
|
| 299 |
+
if (typeof a.pro === 'boolean') room.pro = a.pro;
|
| 300 |
+
break;
|
| 301 |
+
}
|
| 302 |
+
case 'start':
|
| 303 |
+
case 'rematch': {
|
| 304 |
+
if (!isHost) return err('Only the host can start');
|
| 305 |
+
if (a.t === 'start' && room.phase !== 'lobby') return err('Already playing');
|
| 306 |
+
if (a.t === 'rematch' && room.phase !== 'final') return err('Game not finished');
|
| 307 |
+
room.players = room.players.filter((p) => p.on);
|
| 308 |
+
room.players.forEach((p) => { p.waiting = false; });
|
| 309 |
+
if (active(room).length < MIN_PLAYERS) return err(`Need at least ${MIN_PLAYERS} players`);
|
| 310 |
+
resetScores(room);
|
| 311 |
+
room.note = '';
|
| 312 |
+
room.round = 0; room.history = []; room.used = room.used.slice(-60); room.game += 1;
|
| 313 |
+
startRound(room, now, deckFn, r);
|
| 314 |
+
break;
|
| 315 |
+
}
|
| 316 |
+
case 'vote': {
|
| 317 |
+
if (room.phase !== 'vote') return err('Not voting now');
|
| 318 |
+
if (!me || me.waiting || !me.on) return err('Not playing this round');
|
| 319 |
+
if (a.target === a.id || !active(room).some((p) => p.id === a.target)) return err('Pick another player');
|
| 320 |
+
room.cur.votes[a.id] = a.target;
|
| 321 |
+
if (active(room).every((p) => room.cur.votes[p.id])) afterVotes(room, now);
|
| 322 |
+
break;
|
| 323 |
+
}
|
| 324 |
+
case 'guess': {
|
| 325 |
+
if (room.phase !== 'guess') return err('No guess now');
|
| 326 |
+
if (a.id !== room.cur.fox) return err('Only the Fox guesses');
|
| 327 |
+
room.cur.guess = String(a.word || '').slice(0, 60);
|
| 328 |
+
finishRound(room, now);
|
| 329 |
+
break;
|
| 330 |
+
}
|
| 331 |
+
case 'next': {
|
| 332 |
+
if (!isHost) return err('Only the host can skip');
|
| 333 |
+
if (room.phase === 'reveal') { room.phase = 'clue'; room.ends = now + CLUE_MS; room.twistAt = room.ends - TWIST_AT; }
|
| 334 |
+
else if (room.phase === 'clue') { room.phase = 'vote'; room.ends = now + VOTE_MS; }
|
| 335 |
+
else if (room.phase === 'result') return tick(room, now + RESULT_MS + 1, deckFn, r, true);
|
| 336 |
+
else return err('Nothing to skip');
|
| 337 |
+
break;
|
| 338 |
+
}
|
| 339 |
+
case 'kick': {
|
| 340 |
+
if (!isHost || a.target === a.id) return err('Only the host can remove a player');
|
| 341 |
+
return act(room, { t: 'leave', id: a.target, hard: true }, now, deckFn, r);
|
| 342 |
+
}
|
| 343 |
+
default:
|
| 344 |
+
return err('Unknown action');
|
| 345 |
+
}
|
| 346 |
+
room.updated = now;
|
| 347 |
+
return { room };
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
/** Advance on the clock (the Durable Object alarm calls this). */
|
| 351 |
+
export function tick(room, now, deckFn, r = Math.random, cloned = false) {
|
| 352 |
+
if (!cloned) room = structuredClone(room);
|
| 353 |
+
if (!room.ends || now < room.ends) return { room };
|
| 354 |
+
switch (room.phase) {
|
| 355 |
+
case 'reveal': room.phase = 'clue'; room.ends = now + CLUE_MS; room.twistAt = room.ends - TWIST_AT; break;
|
| 356 |
+
case 'clue': room.phase = 'vote'; room.ends = now + VOTE_MS; break;
|
| 357 |
+
case 'vote': afterVotes(room, now); break;
|
| 358 |
+
case 'guess': finishRound(room, now); break;
|
| 359 |
+
case 'result':
|
| 360 |
+
room.round += 1;
|
| 361 |
+
if (room.round >= room.rounds) { room.phase = 'final'; room.ends = 0; }
|
| 362 |
+
else {
|
| 363 |
+
room.players.forEach((p) => { if (p.on) p.waiting = false; });
|
| 364 |
+
if (!toLobbyIfShort(room, now)) startRound(room, now, deckFn, r);
|
| 365 |
+
}
|
| 366 |
+
break;
|
| 367 |
+
default: room.ends = 0;
|
| 368 |
+
}
|
| 369 |
+
room.updated = now;
|
| 370 |
+
return { room };
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
/** The next moment the room needs the clock (phase end or the Twist flip), or 0. */
|
| 374 |
+
export function nextWake(room, now) {
|
| 375 |
+
const t = [room.ends];
|
| 376 |
+
if (room.phase === 'clue' && room.twistAt > now) t.push(room.twistAt);
|
| 377 |
+
const f = t.filter((x) => x && x > 0);
|
| 378 |
+
return f.length ? Math.min(...f) : 0;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
/** The state one player receives: public room + their own card. Never leaks the word to the Fox or the Fox to anyone. */
|
| 382 |
+
export function view(room, id, now) {
|
| 383 |
+
const c = room.cur;
|
| 384 |
+
const playing = c && ['reveal', 'clue', 'vote', 'guess'].includes(room.phase);
|
| 385 |
+
const me = P(room, id);
|
| 386 |
+
const twistOn = room.phase === 'clue' ? now >= room.twistAt : ['vote', 'guess', 'result'].includes(room.phase);
|
| 387 |
+
const out = {
|
| 388 |
+
code: room.code, phase: room.phase, host: room.host, you: id, round: room.round, rounds: room.rounds, packs: room.packs, pro: room.pro,
|
| 389 |
+
ends: room.ends, twistAt: room.twistAt, now, note: room.note || '', game: room.game,
|
| 390 |
+
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]) })),
|
| 391 |
+
history: room.history.map((h) => ({ ...h })),
|
| 392 |
+
};
|
| 393 |
+
if (c) {
|
| 394 |
+
out.cur = { cat: c.cat, starter: c.starter, twist: twistOn ? c.twist : null, votes: room.phase === 'result' ? c.votes : undefined };
|
| 395 |
+
if (playing && me && !me.waiting) out.card = cardFor(c, id, { peek: twistOn });
|
| 396 |
+
if (room.phase === 'guess') { out.cur.accused = c.fox; if (id === c.fox) out.cur.choices = c.choices; }
|
| 397 |
+
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 });
|
| 398 |
+
}
|
| 399 |
+
return out;
|
| 400 |
+
}
|
hushfox/fun.css
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* CyberMax Fun kit: shared look for CyberMax games (copied into each game). Mobile first, light + dark. (c) 2026 CyberMax. */
|
| 2 |
+
:root{--bg:#F6F3EC;--card:#fff;--ink:#11141B;--muted:#565C6B;--line:#E2DCD0;--chip:#ECE6DA;--acc:#FF6A3D;--acc-ink:#11141B;--ok:#0B7F5E;--bad:#C2361B;--shadow:0 1px 2px rgba(17,20,27,.06),0 10px 30px rgba(17,20,27,.08);color-scheme:light}
|
| 3 |
+
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}}
|
| 4 |
+
:root[data-theme=dark]{--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}
|
| 5 |
+
*{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}
|
| 6 |
+
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
|
| 7 |
+
a{color:inherit}img,canvas{max-width:100%}
|
| 8 |
+
.wrap{max-width:760px;margin:0 auto;padding:0 16px}
|
| 9 |
+
.top{display:flex;align-items:center;gap:6px;padding:12px 0}
|
| 10 |
+
.top .brand{display:flex;align-items:center;gap:8px;font-weight:800;text-decoration:none;font-size:18px;margin-right:auto}.top .brand svg{width:28px;height:28px}
|
| 11 |
+
.top nav a,.top .icon{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:10px;font-size:15px;background:none;border:0;font-family:inherit;cursor:pointer;min-height:40px}
|
| 12 |
+
.top nav a:hover,.top .icon:hover{color:var(--ink);background:var(--chip)}
|
| 13 |
+
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:10px 18px;border-radius:12px;border:0;background:var(--acc);color:var(--acc-ink);font:inherit;font-weight:800;font-size:16px;cursor:pointer;text-decoration:none;touch-action:manipulation}
|
| 14 |
+
.btn:hover{filter:brightness(1.06)}.btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)}.btn.sm{min-height:40px;padding:6px 14px;font-size:15px}
|
| 15 |
+
.btn.pass{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B}
|
| 16 |
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
| 17 |
+
.chips{display:flex;gap:6px;flex-wrap:wrap}
|
| 18 |
+
.chip{border:1.5px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:6px 12px;font:inherit;font-size:14px;font-weight:700;cursor:pointer;min-height:36px}
|
| 19 |
+
.chip[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}.chip .lock{opacity:.7;font-size:12px}
|
| 20 |
+
.panel{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:var(--shadow)}
|
| 21 |
+
.muted{color:var(--muted)}.small{font-size:14px}
|
| 22 |
+
h1{font-size:clamp(26px,6vw,40px);line-height:1.08;letter-spacing:-.02em;margin:0 0 6px;text-wrap:balance}
|
| 23 |
+
h2{font-size:21px;letter-spacing:-.01em;margin:30px 0 8px}
|
| 24 |
+
.seo h3{font-size:17px;margin:18px 0 4px}.seo p,.seo li{color:var(--muted)}
|
| 25 |
+
.more{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:12px 0}
|
| 26 |
+
.more a{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:var(--card);text-decoration:none}.more a b{display:block}.more a span{color:var(--muted);font-size:14px}
|
| 27 |
+
footer{color:var(--muted);font-size:14px;padding:26px 0 40px;border-top:1px solid var(--line);margin-top:36px}
|
| 28 |
+
#toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,40px);opacity:0;background:var(--ink);color:var(--bg);padding:12px 16px;border-radius:12px;font-weight:700;transition:.25s;z-index:99;max-width:calc(100% - 32px);text-align:center;pointer-events:none}
|
| 29 |
+
#toast.on{transform:translate(-50%,0);opacity:1}
|
| 30 |
+
.nudge{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;border:1.5px dashed var(--line);border-radius:14px;padding:10px 12px;margin-top:12px;font-size:15px}
|
| 31 |
+
html.cmx-pro .nudge{display:none}
|
| 32 |
+
.pro-badge{display:none;font-size:12px;font-weight:800;background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:2px 8px;vertical-align:middle}
|
| 33 |
+
html.cmx-pro .pro-badge{display:inline-block}
|
| 34 |
+
dialog.funpass{border:0;padding:0;border-radius:20px;max-width:min(520px,calc(100% - 24px));width:100%;background:var(--card);color:var(--ink);box-shadow:0 20px 60px rgba(0,0,0,.35)}
|
| 35 |
+
dialog.funpass::backdrop{background:rgba(10,12,18,.55)}
|
| 36 |
+
.fp{padding:22px 20px 18px;position:relative}.fp h2{margin:2px 0 10px;font-size:24px}
|
| 37 |
+
.fp-x{position:absolute;right:10px;top:8px;border:0;background:none;color:var(--muted);font-size:28px;cursor:pointer;width:44px;height:44px}
|
| 38 |
+
.fp-kick{margin:0;color:var(--acc);font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
|
| 39 |
+
.fp-perks{margin:0 0 14px;padding-left:20px}.fp-perks li{margin:2px 0}
|
| 40 |
+
.fp-plans{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
|
| 41 |
+
.fp-plan{position:relative;display:flex;flex-direction:column;gap:2px;padding:14px 10px 12px;border:1.5px solid var(--line);border-radius:14px;text-decoration:none;text-align:center;background:var(--bg)}
|
| 42 |
+
.fp-plan:hover{border-color:var(--acc)}.fp-plan.best{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
|
| 43 |
+
.fp-plan i{position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:var(--acc);color:#11141B;font-style:normal;font-size:11px;font-weight:800;padding:2px 8px;border-radius:999px;white-space:nowrap}
|
| 44 |
+
.fp-plan b{font-size:14px}.fp-price{font-size:22px;font-weight:900}.fp-price small{font-size:12px;font-weight:600;color:var(--muted)}.fp-note{font-size:12px;color:var(--muted)}
|
| 45 |
+
.fp-small{font-size:13px;color:var(--muted);margin:10px 0 4px}
|
| 46 |
+
.fp-code{display:block;user-select:all;word-break:break-all;background:var(--chip);padding:8px;border-radius:8px;font-size:12px}
|
| 47 |
+
.fp details{margin-top:6px;font-size:14px}.fp-form{display:flex;gap:6px;margin-top:8px}.fp-form input{flex:1;min-width:0;font:inherit;font-size:14px;padding:8px 10px;border-radius:10px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink)}
|
| 48 |
+
@media (max-width:420px){.fp-plans{grid-template-columns:1fr}.fp-plan{flex-direction:row;align-items:center;justify-content:space-between;text-align:left;padding:12px}.fp-plan i{left:auto;right:10px;transform:none}}
|
| 49 |
+
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
| 50 |
+
@media (max-width:560px){.chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -16px;padding:2px 16px 4px}.chips::-webkit-scrollbar{display:none}.chip{flex:0 0 auto}}
|
| 51 |
+
@media (min-width:760px){dialog.funpass{max-width:720px}.fp-perks{columns:2;column-gap:28px}}
|
| 52 |
+
.fp-after{margin:14px 0 0;padding:12px 14px;border:1px solid var(--line, rgba(127,127,127,.35));border-radius:14px;text-align:left}.fp-after p{margin:0 0 8px}.fp-after .fp-row{display:flex;gap:8px;flex-wrap:wrap}
|
| 53 |
+
.fp-game{border-color:var(--acc);display:grid;grid-template-columns:1fr auto;align-items:center;text-align:left;gap:2px 12px;padding:14px 14px 12px;margin:0 0 6px}.fp-game b{font-size:16px}.fp-game .fp-price{grid-row:span 2}.fp-game .fp-note{grid-column:1}
|
| 54 |
+
.fp-or{margin:12px 0 8px;font-size:14px}
|
hushfox/funpass.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax games checkout: the per-game "full game" unlock + the Fun Pass (every game), copied into each game's web/ folder.
|
| 2 |
+
// Model (games lane GM, 9 Oct 2026; evidence docs/ceo/games-monetization-2026-10-09.md):
|
| 3 |
+
// - Free demo: every game's daily challenge stays free for everyone, plus the first levels (OFFER.demo once the per-game
|
| 4 |
+
// unlock is live; levels 1-20 until then). A level the player has opened is never locked again.
|
| 5 |
+
// - Full game: ONE payment per game (OFFER.price, Stripe pack revenue/packs/stripe/<game>-full) unlocks every level,
|
| 6 |
+
// mode, the daily archive and themes in that game, for good. Shown only when OFFER.live is true (owner-approved).
|
| 7 |
+
// - Fun Pass = the all-games bundle: Monthly $2.99 · Yearly $19 · Lifetime $29.99 (pack revenue/packs/stripe/fun-pass).
|
| 8 |
+
// - Portal builds (scripts/games/portals.py) never show a checkout; where the portal SDK offers rewarded ads, the player
|
| 9 |
+
// may watch one to open the full game for an hour (shared/portal-adapter/cmx-portal.js CMXPortal.rewarded()).
|
| 10 |
+
// Buy: counted redirect https://data.cybermaxtools.com/buy/st-<pack>-<plan>?s=game-<id> -> Stripe checkout.
|
| 11 |
+
// After checkout the data Worker (/unlock/fun-pass or /unlock/<game>-full) checks the Checkout Session and returns with
|
| 12 |
+
// #cmx-pro=<code> (the full-game return adds ?unlock=full). The code on the receipt page unlocks any other device.
|
| 13 |
+
// No account, no cookies, nothing personal stored. (c) 2026 CyberMax. All rights reserved.
|
| 14 |
+
import { count, store, toast } from './cmx.js';
|
| 15 |
+
import { OFFER } from './offer.js'; // written by scripts/games/sync_kit.py from products/fun-pass/games.json
|
| 16 |
+
export { OFFER };
|
| 17 |
+
|
| 18 |
+
const DATA = 'https://data.cybermaxtools.com';
|
| 19 |
+
const KEY = 'cmx-pro:fun-pass';
|
| 20 |
+
const SID = /^(cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i; // Stripe order code or Gumroad licence key
|
| 21 |
+
const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
|
| 22 |
+
export const PLANS = [
|
| 23 |
+
{ id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
|
| 24 |
+
{ id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
|
| 25 |
+
{ id: 'unlock', label: 'Lifetime', price: '$29.99', per: ' once', note: 'Pay once, keep forever' },
|
| 26 |
+
];
|
| 27 |
+
export const COMMON_PERKS = ['Every level and mode in every CyberMax game', 'The full archive of past dailies', 'Custom colour themes', 'New games included as they launch'];
|
| 28 |
+
const FULL_KEY = `cmx-full:${OFFER.game}`;
|
| 29 |
+
const REWARD_KEY = `cmx-reward:${OFFER.game}`;
|
| 30 |
+
const REWARD_MS = 60 * 60 * 1000; // one rewarded ad on a portal build opens the full game for an hour
|
| 31 |
+
|
| 32 |
+
let state = store(KEY);
|
| 33 |
+
let full = store(FULL_KEY);
|
| 34 |
+
const listeners = [];
|
| 35 |
+
let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
|
| 36 |
+
|
| 37 |
+
const passOn = () => !!(state && state.code);
|
| 38 |
+
export const ownsGame = () => !!(full && full.code);
|
| 39 |
+
const rewardOn = () => { const r = store(REWARD_KEY); return !!(r && Date.now() - r.at < REWARD_MS); };
|
| 40 |
+
/** True when this player may use everything in this game: Fun Pass, this game's full unlock, or a portal reward hour. */
|
| 41 |
+
export const isPro = () => passOn() || ownsGame() || rewardOn();
|
| 42 |
+
/** Free levels in this game's demo: OFFER.demo once the per-game unlock is live, else the game's current number. */
|
| 43 |
+
export const freeLevels = (current = 20) => (OFFER.live && OFFER.demo ? Math.min(current, OFFER.demo) : current);
|
| 44 |
+
const portal = () => (typeof window !== 'undefined' && window.CMXPortal && window.CMXPortal.portal) || '';
|
| 45 |
+
export function onChange(fn) { listeners.push(fn); fn(isPro()); }
|
| 46 |
+
function emit() {
|
| 47 |
+
document.documentElement.classList.toggle('cmx-pro', isPro());
|
| 48 |
+
listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
async function check(code, what = 'fun-pass') {
|
| 52 |
+
try {
|
| 53 |
+
const r = await fetch(`${DATA}/unlock/${what}?json=1&session_id=${encodeURIComponent(code)}`);
|
| 54 |
+
const j = await r.json();
|
| 55 |
+
return j && typeof j.pro === 'boolean' ? j.pro : null;
|
| 56 |
+
} catch { return null; }
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** Turn on a full-game code for this game (receipt code of the <game>-full Payment Link). */
|
| 60 |
+
export async function unlockGame(code) {
|
| 61 |
+
const ok = await check(code, `${OFFER.game}-full`);
|
| 62 |
+
if (ok) {
|
| 63 |
+
full = store(FULL_KEY, { code, at: new Date().toISOString() });
|
| 64 |
+
count(cfg.game, 'pro', 'full-unlocked');
|
| 65 |
+
toast(`${OFFER.name}: the full game is on. Thank you for supporting CyberMax games!`, 3500);
|
| 66 |
+
emit(); render();
|
| 67 |
+
} else if (ok === false) toast('That code is not a full-game order for this game.', 3500);
|
| 68 |
+
else toast('Could not reach the unlock check. Try again in a minute.', 3500);
|
| 69 |
+
return ok;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** Turn on a pasted or returned code: a Fun Pass code, or (where this game sells its own unlock) a full-game code. */
|
| 73 |
+
export async function unlock(code) {
|
| 74 |
+
const ok = await check(code);
|
| 75 |
+
if (ok) {
|
| 76 |
+
state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
|
| 77 |
+
count(cfg.game, 'pro', 'unlocked');
|
| 78 |
+
toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
|
| 79 |
+
emit();
|
| 80 |
+
render();
|
| 81 |
+
return ok;
|
| 82 |
+
}
|
| 83 |
+
if (sellsGame() && (await check(code, `${OFFER.game}-full`))) return unlockGame(code);
|
| 84 |
+
if (ok === false) toast('That code is not an active Fun Pass.', 3500);
|
| 85 |
+
else toast('Could not reach the pass check. Try again in a minute.', 3500);
|
| 86 |
+
return ok;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function recheck() {
|
| 90 |
+
if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
|
| 91 |
+
const ok = await check(state.code);
|
| 92 |
+
if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
|
| 93 |
+
else if (ok) state = store(KEY, { ...state, checked: Date.now() });
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 97 |
+
const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
|
| 98 |
+
const gameUrl = (from = '') => `${DATA}/buy/st-${OFFER.game}-full-unlock?s=game-${cfg.game}${from ? `-${from}` : ''}`;
|
| 99 |
+
const sellsGame = () => !!(OFFER.live && OFFER.price && OFFER.game !== 'games');
|
| 100 |
+
|
| 101 |
+
function dialog() {
|
| 102 |
+
let d = document.getElementById('funpass');
|
| 103 |
+
if (!d) {
|
| 104 |
+
d = document.createElement('dialog');
|
| 105 |
+
d.id = 'funpass';
|
| 106 |
+
d.className = 'funpass';
|
| 107 |
+
document.body.appendChild(d);
|
| 108 |
+
d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
|
| 109 |
+
}
|
| 110 |
+
return d;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
const planHtml = () => PLANS.map((p) => `<a class="fp-plan${p.best ? ' best' : ''}" href="${buyUrl(p.id)}" rel="nofollow" data-plan="${p.id}">${p.best ? '<i>Best value</i>' : ''}<b>${p.label}</b><span class="fp-price">${p.price}<small>${p.per}</small></span><span class="fp-note">${p.note}</span></a>`).join('');
|
| 114 |
+
const codeForm = `<details><summary>Already bought?</summary><form class="fp-form"><input aria-label="Order code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details>`;
|
| 115 |
+
|
| 116 |
+
function render() {
|
| 117 |
+
const d = dialog();
|
| 118 |
+
const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
|
| 119 |
+
if (isPro()) {
|
| 120 |
+
const what = passOn() ? 'The Fun Pass is on in this browser for every CyberMax game' : ownsGame() ? `You own the full ${esc(OFFER.name)}` : 'The full game is open for the next hour (rewarded ad)';
|
| 121 |
+
const code = passOn() ? state.code : ownsGame() ? full.code : '';
|
| 122 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${passOn() ? 'CyberMax Fun Pass' : esc(OFFER.name)}</p><h2>Everything is open. Thank you!</h2>
|
| 123 |
+
<p>${what}: ${perks.map(esc).join(' · ')}.</p>
|
| 124 |
+
${code ? `<p class="fp-small">Your code (paste it on another device to turn it on there):</p><code class="fp-code">${esc(code)}</code>` : ''}
|
| 125 |
+
${passOn() ? '<p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p>' : ''}</div>`;
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
if (portal()) { // portal build: no outside checkout; a rewarded ad (when the portal offers one) opens the game for an hour
|
| 129 |
+
d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name || 'Full game')}</p>
|
| 130 |
+
<h2>Open the full game for an hour</h2><ul class="fp-perks">${perks.slice(0, 4).map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 131 |
+
<p><button class="btn" type="button" data-reward>▶ Watch a short ad</button></p>
|
| 132 |
+
<p class="fp-small">Optional. The daily challenge and the free levels stay free without ads.</p></div>`;
|
| 133 |
+
d.querySelector('[data-reward]').addEventListener('click', async () => {
|
| 134 |
+
count(cfg.game, 'cta', 'reward-ad');
|
| 135 |
+
const ok = await window.CMXPortal.rewarded().catch(() => false);
|
| 136 |
+
if (ok) { store(REWARD_KEY, { at: Date.now() }); count(cfg.game, 'pro', 'reward-hour'); toast('Everything is open for the next hour. Have fun!', 3000); emit(); d.close(); }
|
| 137 |
+
else toast('No ad is available right now. Try again later.', 3000);
|
| 138 |
+
});
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
const allGames = `${OFFER.games || 'every'} CyberMax games`;
|
| 142 |
+
const own = cfg.perks || [];
|
| 143 |
+
const gamePerks = [...own, ...(own.some((p) => /archive/i.test(p)) ? [] : ['The full archive of past dailies']), 'Every bonus mode in this game'].slice(0, 6);
|
| 144 |
+
d.innerHTML = sellsGame()
|
| 145 |
+
? `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${esc(OFFER.name)} · full game</p>
|
| 146 |
+
<h2>Unlock the full ${esc(OFFER.name)}</h2>
|
| 147 |
+
<ul class="fp-perks">${gamePerks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 148 |
+
<a class="fp-plan fp-game" href="${gameUrl()}" rel="nofollow" data-game><i>This game</i><b>Full ${esc(OFFER.name)}</b><span class="fp-price">${esc(OFFER.price)}<small> once</small></span><span class="fp-note">Pay once, keep it forever. No subscription.</span></a>
|
| 149 |
+
<p class="fp-or"><b>Or get all ${esc(allGames)}</b> with the Fun Pass:</p>
|
| 150 |
+
<div class="fp-plans">${planHtml()}</div>
|
| 151 |
+
<p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · your code works on any device · 14-day promise: if it doesn't work as described, we fix it or refund you. The daily challenge and the first ${OFFER.demo || ''} levels stay free for everyone.</p>
|
| 152 |
+
${codeForm}</div>`
|
| 153 |
+
: `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
|
| 154 |
+
<h2>One pass. Every game. More to play.</h2>
|
| 155 |
+
<ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
|
| 156 |
+
<div class="fp-plans">${planHtml()}</div>
|
| 157 |
+
<p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The daily challenges and the free levels stay free for everyone.</p>
|
| 158 |
+
${codeForm}</div>`;
|
| 159 |
+
d.querySelector('.fp-form').addEventListener('submit', (e) => {
|
| 160 |
+
e.preventDefault();
|
| 161 |
+
const c = e.target.querySelector('input').value.trim();
|
| 162 |
+
if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
|
| 163 |
+
});
|
| 164 |
+
d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
|
| 165 |
+
d.querySelector('[data-game]')?.addEventListener('click', () => count(cfg.game, 'cta', 'full-game'));
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/** Open the pass sheet; `from` names the button that opened it (counted as cta). */
|
| 169 |
+
export function open(from = 'button') {
|
| 170 |
+
render();
|
| 171 |
+
const d = dialog();
|
| 172 |
+
if (d.showModal) d.showModal(); else d.setAttribute('open', '');
|
| 173 |
+
count(cfg.game, 'cta', `pass-open-${from}`);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
/** After a finished round (the value moment): one small inline card under the result, never a pop-up. Shows the
|
| 177 |
+
* pass price, the honest trust line and an optional "email me" weekly reminder (double opt-in).
|
| 178 |
+
* `host` is the result panel; the card is added once and removed for pass holders. */
|
| 179 |
+
export function afterWin(host, from = 'win') {
|
| 180 |
+
if (!host) return;
|
| 181 |
+
if (isPro() || portal()) { host.querySelector('.fp-after')?.remove(); return; }
|
| 182 |
+
let el = host.querySelector('.fp-after');
|
| 183 |
+
if (!el) { el = document.createElement('div'); el.className = 'fp-after'; host.appendChild(el); }
|
| 184 |
+
const best = PLANS.find((p) => p.best) || PLANS[0];
|
| 185 |
+
const game = sellsGame();
|
| 186 |
+
const href = game ? gameUrl('after') : `${buyUrl(best.id)}-after`;
|
| 187 |
+
const label = game ? `Get the full ${OFFER.name}: ${OFFER.price} once` : `Get the Fun Pass: ${best.price}${best.per}`;
|
| 188 |
+
const line = game ? (cfg.extra || `The full game opens every level, mode, the daily archive and themes in ${OFFER.name}.`)
|
| 189 |
+
: (cfg.extra || 'The Fun Pass opens every level, mode, the full archive and themes in every CyberMax game.');
|
| 190 |
+
el.innerHTML = `<p><b>Want more today?</b> ${esc(line)}</p>
|
| 191 |
+
<p class="fp-row"><a class="btn sm" href="${href}" rel="nofollow" data-after>${esc(label)}</a> <button class="btn sm ghost" type="button" data-plans>${game ? 'All games pass' : 'See all plans'}</button></p>
|
| 192 |
+
<p class="fp-small">Secure Stripe checkout · turns on as soon as you pay · ${game ? 'one payment, no subscription' : 'cancel any time from your receipt email'} · 14-day promise: if it doesn't work as described, we fix it or refund you (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). Today's daily challenge stays free.</p>
|
| 193 |
+
<div class="fp-cap"></div>`;
|
| 194 |
+
el.querySelector('[data-after]').addEventListener('click', () => { count(cfg.game, 'cta', game ? 'full-game-after' : `pass-${best.id}-after`); kit((K) => K.event('upgrade-click', cfg.game)); });
|
| 195 |
+
el.querySelector('[data-plans]').addEventListener('click', () => open(`after-${from}`));
|
| 196 |
+
kit((K) => {
|
| 197 |
+
K.event('result', cfg.game); K.event('upgrade-view', cfg.game);
|
| 198 |
+
K.capture(el.querySelector('.fp-cap'), { list: `weekly-${cfg.game}`, src: `game-${cfg.game}`, label: cfg.remind || 'Email me a weekly reminder with new puzzles (free)', button: 'Remind me' });
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
// Convert kit (funnel beacons + email capture) from the CyberMax site; optional, the game works without it.
|
| 203 |
+
let kitQ = null;
|
| 204 |
+
function kit(fn) {
|
| 205 |
+
if (window.CMXConvert) return fn(window.CMXConvert);
|
| 206 |
+
if (kitQ) return kitQ.push(fn);
|
| 207 |
+
kitQ = [fn];
|
| 208 |
+
const sc = document.createElement('script');
|
| 209 |
+
sc.src = 'https://cybermaxtools.com/store/convert.js'; sc.async = true;
|
| 210 |
+
sc.onload = () => kitQ.splice(0).forEach((f) => { try { f(window.CMXConvert); } catch { /* optional */ } });
|
| 211 |
+
document.head.appendChild(sc);
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
/** Call once per page: game id, display name and the game's own perks (listed first). Reads #cmx-pro=<code> after
|
| 215 |
+
* checkout (?unlock=full marks this game's full-game return). Skin-pack codes are taken first by skins.js capture(). */
|
| 216 |
+
export function init(options) {
|
| 217 |
+
cfg = { ...cfg, ...options };
|
| 218 |
+
const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
|
| 219 |
+
if (m && SID.test(decodeURIComponent(m[1]))) {
|
| 220 |
+
const q = new URLSearchParams(location.search);
|
| 221 |
+
const fullReturn = q.get('unlock') === 'full';
|
| 222 |
+
q.delete('unlock');
|
| 223 |
+
history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
|
| 224 |
+
if (fullReturn) unlockGame(decodeURIComponent(m[1])); else unlock(decodeURIComponent(m[1]));
|
| 225 |
+
} else recheck();
|
| 226 |
+
emit();
|
| 227 |
+
}
|
hushfox/game.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hushfox UI: pass-the-phone party mode (works offline), room-code mode (each player on their own phone), and the
|
| 2 |
+
// solo Fox Hunt (daily + 30 levels). Rules live in core.js; the room runs on the hushfox Worker (net.js).
|
| 3 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 4 |
+
import * as C from './core.js';
|
| 5 |
+
import { PACKS, deck, packById } from './packs.js';
|
| 6 |
+
import { count, store, toast, share, pwa, themeToggle, canvasBlob, download, today, dayNumber, SITE } from './cmx.js';
|
| 7 |
+
import { progress, daily, readChallenge, challengeLink, shareChallenge, sfx, burstAt, shake, renderMap, scoreCard, starsFor, starsHtml, css as lvCss } from './levels.js';
|
| 8 |
+
import * as pass from './funpass.js';
|
| 9 |
+
import { apiBase, createRoom, roomInfo, connect } from './net.js';
|
| 10 |
+
|
| 11 |
+
const GAME = 'hushfox';
|
| 12 |
+
const PATH = '/games/hushfox/';
|
| 13 |
+
const stage = document.getElementById('stage');
|
| 14 |
+
const $ = (id) => document.getElementById(id);
|
| 15 |
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
| 16 |
+
let muted = !!store(`${GAME}:muted`);
|
| 17 |
+
const snd = sfx(() => muted);
|
| 18 |
+
const buzz = (p) => { try { if (!muted && navigator.vibrate) navigator.vibrate(p); } catch { /* */ } };
|
| 19 |
+
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>';
|
| 20 |
+
const fox = (cls = '') => FOX_SVG.replace('class="fox"', `class="fox ${cls}"`);
|
| 21 |
+
|
| 22 |
+
let tick = 0; // the one running interval (timers); cleared on every screen change
|
| 23 |
+
function show(html, cls = '') {
|
| 24 |
+
clearInterval(tick);
|
| 25 |
+
clearInterval(huntTick);
|
| 26 |
+
stage.innerHTML = `<div class="scr ${cls}">${html}</div>`;
|
| 27 |
+
stage.scrollTop = 0;
|
| 28 |
+
return stage.firstElementChild;
|
| 29 |
+
}
|
| 30 |
+
function on(id, fn) { const el = $(id); if (el) el.addEventListener('click', (e) => { snd.tap(); fn(e); }); return el; }
|
| 31 |
+
const isPro = () => pass.isPro();
|
| 32 |
+
|
| 33 |
+
// ------------------------------------------------------------------ shared bits
|
| 34 |
+
|
| 35 |
+
const LS_NAMES = `${GAME}:names`, LS_PACKS = `${GAME}:packs`, LS_ROUNDS = `${GAME}:rounds`, LS_PID = `${GAME}:pid`, LS_ME = `${GAME}:me`;
|
| 36 |
+
function pid() {
|
| 37 |
+
let id = store(LS_PID);
|
| 38 |
+
if (!id) { id = `p${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36).slice(-4)}`; store(LS_PID, id); }
|
| 39 |
+
return id;
|
| 40 |
+
}
|
| 41 |
+
function packChoice() {
|
| 42 |
+
const saved = store(LS_PACKS) || ['classic'];
|
| 43 |
+
const ok = saved.filter((id) => packById(id) && (!packById(id).pass || isPro()));
|
| 44 |
+
return ok.length ? ok : ['classic'];
|
| 45 |
+
}
|
| 46 |
+
function packsHtml(sel, canEdit = true) {
|
| 47 |
+
return `<div class="packs" role="group" aria-label="Packs">${PACKS.map((p) => {
|
| 48 |
+
const locked = p.pass && !isPro();
|
| 49 |
+
const n = p.cats.reduce((a, c) => a + c[2].split('|').length, 0);
|
| 50 |
+
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>`;
|
| 51 |
+
}).join('')}</div>`;
|
| 52 |
+
}
|
| 53 |
+
function bindPacks(sel, onChange) {
|
| 54 |
+
stage.querySelectorAll('[data-pack]').forEach((b) => b.addEventListener('click', () => {
|
| 55 |
+
const p = packById(b.dataset.pack);
|
| 56 |
+
if (p.pass && !isPro()) { pass.open(`pack-${p.id}`); return; }
|
| 57 |
+
snd.tap();
|
| 58 |
+
const i = sel.indexOf(p.id);
|
| 59 |
+
if (i >= 0) { if (sel.length > 1) sel.splice(i, 1); } else sel.push(p.id);
|
| 60 |
+
stage.querySelectorAll('[data-pack]').forEach((x) => x.setAttribute('aria-pressed', String(sel.includes(x.dataset.pack))));
|
| 61 |
+
store(LS_PACKS, sel);
|
| 62 |
+
onChange && onChange(sel);
|
| 63 |
+
}));
|
| 64 |
+
}
|
| 65 |
+
function roundsHtml(n, canEdit = true) {
|
| 66 |
+
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>`;
|
| 67 |
+
}
|
| 68 |
+
function bindRounds(onPick) {
|
| 69 |
+
stage.querySelectorAll('[data-rounds]').forEach((b) => b.addEventListener('click', () => {
|
| 70 |
+
snd.tap();
|
| 71 |
+
const n = Number(b.dataset.rounds);
|
| 72 |
+
stage.querySelectorAll('[data-rounds]').forEach((x) => x.setAttribute('aria-pressed', String(x === b)));
|
| 73 |
+
store(LS_ROUNDS, n);
|
| 74 |
+
onPick(n);
|
| 75 |
+
}));
|
| 76 |
+
}
|
| 77 |
+
function timerHtml() {
|
| 78 |
+
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>`;
|
| 79 |
+
}
|
| 80 |
+
/** Drive the ring until `ends` (ms, local clock). onTwist fires once at TWIST_AT left; onEnd at zero. */
|
| 81 |
+
function runTimer(ends, total, { twistAt = 0, onTwist, onEnd } = {}) {
|
| 82 |
+
let twisted = !twistAt || Date.now() >= twistAt, lastSec = -1;
|
| 83 |
+
if (twisted && twistAt && onTwist) onTwist(true);
|
| 84 |
+
const step = () => {
|
| 85 |
+
const left = Math.max(0, ends - Date.now());
|
| 86 |
+
const s = Math.ceil(left / 1000);
|
| 87 |
+
const ring = $('ring'), secs = $('secs'), t = $('timer');
|
| 88 |
+
if (!ring) { clearInterval(tick); return; }
|
| 89 |
+
ring.style.strokeDashoffset = String(326.7 * (1 - left / total));
|
| 90 |
+
if (s !== lastSec) {
|
| 91 |
+
secs.textContent = s; lastSec = s;
|
| 92 |
+
t.classList.toggle('hurry', s <= 5 && s > 0);
|
| 93 |
+
if (s <= 5 && s > 0) snd.tone(520 + (5 - s) * 60, 0.05, 'square', 0.04, 1);
|
| 94 |
+
}
|
| 95 |
+
if (!twisted && twistAt && Date.now() >= twistAt) { twisted = true; onTwist && onTwist(false); }
|
| 96 |
+
if (left <= 0) { clearInterval(tick); onEnd && onEnd(); }
|
| 97 |
+
};
|
| 98 |
+
step();
|
| 99 |
+
tick = setInterval(step, 200);
|
| 100 |
+
}
|
| 101 |
+
function twistHtml(id, waiting = false) {
|
| 102 |
+
if (waiting) return '<div class="twist wait" id="twist"><i>🃏</i><span>Twist card flips at 15 seconds…</span></div>';
|
| 103 |
+
const t = C.twistById(id);
|
| 104 |
+
return `<div class="twist flip" id="twist"><i>${t.icon}</i><span>TWIST! ${esc(t.text)}</span></div>`;
|
| 105 |
+
}
|
| 106 |
+
function flipTwist(id, quiet) {
|
| 107 |
+
const el = $('twist');
|
| 108 |
+
if (el) el.outerHTML = twistHtml(id);
|
| 109 |
+
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 }); }
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
// ------------------------------------------------------------------ home
|
| 113 |
+
|
| 114 |
+
function home() {
|
| 115 |
+
const dd = dayNumber(today());
|
| 116 |
+
show(`${fox()}<p class="logo">HUSH<i>FOX</i></p><p class="lead">Everyone knows the word. Except the Fox.</p>
|
| 117 |
+
<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>
|
| 118 |
+
<button class="big" id="h-room" type="button"><b>🔗 Room code</b><span>Everyone joins on their own phone</span></button>
|
| 119 |
+
<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>
|
| 120 |
+
<button class="lnk" id="h-how" type="button">How to play</button>`);
|
| 121 |
+
on('h-pass', () => passSetup());
|
| 122 |
+
on('h-room', () => roomHome());
|
| 123 |
+
on('h-hunt', () => huntHome());
|
| 124 |
+
on('h-how', () => howTo());
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function howTo() {
|
| 128 |
+
show(`<p class="out">How to play</p>
|
| 129 |
+
<ol class="lead" style="text-align:left;font-weight:600;font-size:18px;padding-left:1.2em;max-width:34ch">
|
| 130 |
+
<li>Everyone secretly sees the word. The <b>Fox</b> only sees the category.</li>
|
| 131 |
+
<li>Go round the table: one clue each, out loud. Don't make it too easy!</li>
|
| 132 |
+
<li>At 15 seconds a <b>Twist card</b> flips (mime it, one syllable, opposites…). Do a second lap with the twist.</li>
|
| 133 |
+
<li>At zero, count 3-2-1 and point at the Fox.</li>
|
| 134 |
+
<li>Caught? The Fox can still <b>steal</b> the round by picking the word from six.</li></ol>
|
| 135 |
+
<p class="small muted2">Fox escapes: +2 · each finger on the Fox: +1 · Fox steals the word: +2</p>
|
| 136 |
+
<button class="btn go" id="b-back" type="button">Got it</button>`);
|
| 137 |
+
on('b-back', home);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// ------------------------------------------------------------------ pass-the-phone mode (offline)
|
| 141 |
+
|
| 142 |
+
let G = null; // the local game
|
| 143 |
+
|
| 144 |
+
function passSetup(keep) {
|
| 145 |
+
let names = keep || store(LS_NAMES) || ['', '', ''];
|
| 146 |
+
while (names.length < 3) names.push('');
|
| 147 |
+
let rounds = store(LS_ROUNDS) || 5;
|
| 148 |
+
const sel = packChoice();
|
| 149 |
+
let useDaily = false;
|
| 150 |
+
const draw = () => {
|
| 151 |
+
show(`<p class="out">Who's playing?</p>
|
| 152 |
+
<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>
|
| 153 |
+
${names.length < C.MAX_PLAYERS ? '<button class="btn ghost sm" id="add" type="button">+ Add player</button>' : ''}
|
| 154 |
+
<div class="field"><label>Rounds</label>${roundsHtml(rounds)}</div>
|
| 155 |
+
<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>
|
| 156 |
+
<div class="field"><label>Packs</label>${packsHtml(sel)}</div>
|
| 157 |
+
<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>
|
| 158 |
+
<button class="btn go" id="start" type="button">▶ Start (${names.length} players)</button>
|
| 159 |
+
<button class="lnk" id="back" type="button">Back</button>`, 'top');
|
| 160 |
+
stage.querySelectorAll('[data-i]').forEach((el) => el.addEventListener('input', () => { names[Number(el.dataset.i)] = el.value; }));
|
| 161 |
+
stage.querySelectorAll('[data-del]').forEach((b) => b.addEventListener('click', () => { names.splice(Number(b.dataset.del), 1); draw(); }));
|
| 162 |
+
on('add', () => { names.push(''); draw(); const l = stage.querySelectorAll('[data-i]'); l[l.length - 1].focus(); });
|
| 163 |
+
bindRounds((n) => { rounds = n; });
|
| 164 |
+
bindPacks(sel);
|
| 165 |
+
on('dly', () => { useDaily = !useDaily; $('dly').setAttribute('aria-pressed', String(useDaily)); if (useDaily) rounds = 5; });
|
| 166 |
+
on('ai', () => aiCategory());
|
| 167 |
+
on('back', home);
|
| 168 |
+
on('start', () => {
|
| 169 |
+
const clean = names.map((n, i) => C.cleanName(n || `Player ${i + 1}`));
|
| 170 |
+
const uniq = []; for (const n of clean) uniq.push(C.uniqueName(n, uniq));
|
| 171 |
+
store(LS_NAMES, uniq);
|
| 172 |
+
newPassGame(uniq, useDaily ? 5 : rounds, sel.slice(), useDaily);
|
| 173 |
+
});
|
| 174 |
+
if (aiExtra.length) $('ai-note').textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
|
| 175 |
+
};
|
| 176 |
+
draw();
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
let aiExtra = [];
|
| 180 |
+
async function aiCategory() {
|
| 181 |
+
const note = $('ai-note');
|
| 182 |
+
if (!navigator.onLine) { toast('Offline: the built-in decks work without internet.'); return; }
|
| 183 |
+
const theme = prompt('A theme for the surprise category (or leave empty for a random one):', '') ?? null;
|
| 184 |
+
if (theme === null) return;
|
| 185 |
+
note.textContent = 'Asking the AI…';
|
| 186 |
+
try {
|
| 187 |
+
const r = await fetch(`${apiBase()}/api/ai?theme=${encodeURIComponent(theme.slice(0, 30))}`);
|
| 188 |
+
const j = await r.json();
|
| 189 |
+
if (!r.ok || !j.cat) throw new Error(j.error || 'No category');
|
| 190 |
+
aiExtra = [...aiExtra.filter((x) => x.cat !== j.cat), { cat: j.cat, words: j.words }].slice(-3);
|
| 191 |
+
note.textContent = `Added: ${aiExtra.map((x) => x.cat).join(', ')}`;
|
| 192 |
+
count(GAME, 'cta', 'ai-category');
|
| 193 |
+
} catch (e) { note.textContent = `${e.message || 'AI is busy'}. The built-in decks are ready.`; }
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
function localDeck(packs) {
|
| 197 |
+
const d = deck(packs, isPro());
|
| 198 |
+
return [...(d.length ? d : deck(['classic'])), ...aiExtra.map((x) => ({ ...x, group: 'ai', pack: 'ai', lang: 'en' }))];
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function newPassGame(names, rounds, packs, useDaily) {
|
| 202 |
+
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() };
|
| 203 |
+
count(GAME, 'play', useDaily ? 'pass-daily' : 'pass');
|
| 204 |
+
passDeal();
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function passDeal() {
|
| 208 |
+
const ids = G.players.map((p) => p.id);
|
| 209 |
+
const r = C.rng(`${G.seed}-${G.round}-${Date.now()}`);
|
| 210 |
+
const d = localDeck(G.packs);
|
| 211 |
+
let cur = C.deal(d, ids, r, { used: G.used, round: G.round });
|
| 212 |
+
if (G.useDaily) {
|
| 213 |
+
const dr = C.dailyRounds(deck(['classic']), today())[G.round % 5];
|
| 214 |
+
const cat = deck(['classic']).find((c) => c.cat === dr.cat);
|
| 215 |
+
const others = C.shuffle(cat.words.filter((w) => w !== dr.word), r).slice(0, C.CHOICES - 1);
|
| 216 |
+
cur = { ...cur, cat: dr.cat, word: dr.word, twist: dr.twist, choices: C.shuffle([dr.word, ...others], r) };
|
| 217 |
+
}
|
| 218 |
+
// one shared phone can't show the Fox a private letter mid-round, so the peek twist is swapped for another
|
| 219 |
+
if (cur.twist === 'peek') cur.twist = C.TWISTS.filter((t) => t.id !== 'peek')[Math.floor(r() * (C.TWISTS.length - 1))].id;
|
| 220 |
+
G.cur = cur;
|
| 221 |
+
G.used.add(`${cur.cat}|${cur.word}`);
|
| 222 |
+
passTo(0);
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
const nameOf = (id) => (G.players.find((p) => p.id === id) || {}).name || '?';
|
| 226 |
+
|
| 227 |
+
function passTo(i) {
|
| 228 |
+
const p = G.players[i];
|
| 229 |
+
show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds} · card ${i + 1} of ${G.players.length}</p>
|
| 230 |
+
<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>
|
| 231 |
+
<p class="lead">Only ${esc(p.name)} looks!</p>
|
| 232 |
+
<button class="btn go" id="see" type="button">👀 I'm ${esc(p.name)}: show my card</button>`);
|
| 233 |
+
on('see', () => cardFor(i));
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
function cardFor(i) {
|
| 237 |
+
const p = G.players[i];
|
| 238 |
+
const c = C.cardFor(G.cur, p.id);
|
| 239 |
+
const last = i === G.players.length - 1;
|
| 240 |
+
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>`
|
| 241 |
+
: `<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>`}
|
| 242 |
+
<button class="btn go" id="hide" type="button">${last ? '🙈 Hide & start the clues' : `🙈 Hide & pass to ${esc(G.players[i + 1].name)}`}</button>`);
|
| 243 |
+
if (c.fox) buzz([30, 30, 30]); else buzz(20);
|
| 244 |
+
on('hide', () => (last ? passClues() : passTo(i + 1)));
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function passClues() {
|
| 248 |
+
const ends = Date.now() + C.CLUE_MS;
|
| 249 |
+
show(`<p class="small muted2">Round ${G.round + 1} of ${G.rounds}</p><p class="cat">${esc(G.cur.cat)}</p>
|
| 250 |
+
<p class="lead">🎤 ${esc(nameOf(G.cur.starter))} starts. One clue each, go round!</p>${timerHtml()}${twistHtml(G.cur.twist, true)}
|
| 251 |
+
<button class="btn ghost sm" id="skip" type="button">Everyone's done: vote now</button>`);
|
| 252 |
+
snd.win();
|
| 253 |
+
runTimer(ends, C.CLUE_MS, { twistAt: ends - C.TWIST_AT, onTwist: (q) => flipTwist(G.cur.twist, q), onEnd: () => { snd.hit(); buzz(200); passVote(); } });
|
| 254 |
+
on('skip', passVote);
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function passVote() {
|
| 258 |
+
show(`<p class="out">Where's the Fox?</p><p class="lead">Count 3-2-1 and everyone points at once!</p>
|
| 259 |
+
<button class="btn go" id="count" type="button">3 · 2 · 1</button>
|
| 260 |
+
<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>`);
|
| 261 |
+
on('count', () => {
|
| 262 |
+
const b = $('count');
|
| 263 |
+
let n = 3;
|
| 264 |
+
b.disabled = true;
|
| 265 |
+
const t = setInterval(() => {
|
| 266 |
+
snd.tone(440 + (3 - n) * 120, 0.12, 'square', 0.05, 1);
|
| 267 |
+
b.textContent = n > 0 ? String(n) : 'POINT! 👉';
|
| 268 |
+
if (n-- <= 0) { clearInterval(t); buzz(120); $('pick').hidden = false; }
|
| 269 |
+
}, 550);
|
| 270 |
+
});
|
| 271 |
+
stage.querySelectorAll('[data-acc]').forEach((b) => b.addEventListener('click', () => {
|
| 272 |
+
snd.tap();
|
| 273 |
+
const acc = b.dataset.acc;
|
| 274 |
+
G.votes = {};
|
| 275 |
+
if (acc) for (const p of G.players) if (p.id !== acc) G.votes[p.id] = acc;
|
| 276 |
+
if (acc && acc === G.cur.fox) passGuess(); else passResult(null);
|
| 277 |
+
}));
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
function passGuess() {
|
| 281 |
+
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>
|
| 282 |
+
<div class="grid">${G.cur.choices.map((w) => `<button class="pick" type="button" data-w="${esc(w)}">${esc(w)}</button>`).join('')}</div>`);
|
| 283 |
+
burstAt(stage.querySelector('.fox'), 0.5, 0.5);
|
| 284 |
+
snd.good(8);
|
| 285 |
+
stage.querySelectorAll('[data-w]').forEach((b) => b.addEventListener('click', () => {
|
| 286 |
+
const ok = C.norm(b.dataset.w) === C.norm(G.cur.word);
|
| 287 |
+
b.classList.add(ok ? 'good' : 'bad');
|
| 288 |
+
if (!ok) stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`)?.classList.add('good');
|
| 289 |
+
setTimeout(() => passResult(b.dataset.w), 700);
|
| 290 |
+
}));
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
function passResult(guess) {
|
| 294 |
+
const s = C.score({ fox: G.cur.fox, word: G.cur.word, votes: G.votes || {}, guess });
|
| 295 |
+
for (const [id, n] of Object.entries(s.deltas)) G.players.find((p) => p.id === id).score += n;
|
| 296 |
+
G.history.push({ cat: G.cur.cat, word: G.cur.word, fox: G.cur.fox, foxName: nameOf(G.cur.fox), outcome: s.outcome });
|
| 297 |
+
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 },
|
| 298 |
+
{ next: () => { G.round++; if (G.round >= G.rounds) passFinal(); else passDeal(); } });
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function resultScreen(r, { next, host = true }) {
|
| 302 |
+
const last = r.round + 1 >= r.rounds;
|
| 303 |
+
const sub = r.outcome === 'escaped' ? (r.accusedName ? `You pointed at ${esc(r.accusedName)}. Wrong!` : 'The table split, and the Fox slipped away.')
|
| 304 |
+
: 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.';
|
| 305 |
+
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>
|
| 306 |
+
<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>
|
| 307 |
+
<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>
|
| 308 |
+
${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>'}`);
|
| 309 |
+
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')); }
|
| 310 |
+
if (host) on('next', next);
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function passFinal() {
|
| 314 |
+
const scores = Object.fromEntries(G.players.map((p) => [p.id, p.score]));
|
| 315 |
+
finalScreen({ players: G.players, history: G.history, winners: C.winners(scores), daily: G.useDaily },
|
| 316 |
+
{ rematch: () => { G.players.forEach((p) => { p.score = 0; }); newPassGame(G.players.map((p) => p.name), G.rounds, G.packs, G.useDaily); },
|
| 317 |
+
edit: () => passSetup(G.players.map((p) => p.name)) });
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function finalScreen({ players, history, winners, daily: dly }, { rematch, edit, host = true, waitingNote = '' }) {
|
| 321 |
+
const names = winners.map((id) => players.find((p) => p.id === id)?.name).filter(Boolean);
|
| 322 |
+
const line = C.emojiLine(history);
|
| 323 |
+
show(`${fox()}<p class="out">${names.length ? `🏆 ${esc(names.join(' & '))} ${names.length > 1 ? 'win' : 'wins'}!` : 'Nobody scored. Sneaky foxes!'}</p>
|
| 324 |
+
<p class="emoji" aria-label="Round results">${line}</p>
|
| 325 |
+
<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>
|
| 326 |
+
${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>`}
|
| 327 |
+
<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>
|
| 328 |
+
<div id="fp"></div>`);
|
| 329 |
+
snd.win(); [0, 1, 2].forEach((i) => setTimeout(() => burstAt(stage.querySelector('.out'), 0.2 + i * 0.3, 0.5), i * 180));
|
| 330 |
+
const text = `Hushfox 🦊 ${history.length} rounds${dly ? ` · Daily deck #${dayNumber(today())}` : ''}\n${line}\n${names.length ? `Winner: ${names.join(' & ')}` : 'The Fox won the night'}`;
|
| 331 |
+
if (host) on('again', () => { count(GAME, 'play', 'rematch'); rematch(); });
|
| 332 |
+
on('share', async () => { const b = await canvasBlob(partyCard(players, history, names)); share(GAME, text, PATH, b, 'hushfox.png'); });
|
| 333 |
+
on('card', async () => { download(await canvasBlob(partyCard(players, history, names)), 'hushfox.png'); count(GAME, 'share', 'card'); });
|
| 334 |
+
if (edit) on('edit', edit);
|
| 335 |
+
on('menu', () => { leaveRoom(); home(); });
|
| 336 |
+
count(GAME, 'win', 'party');
|
| 337 |
+
pass.afterWin($('fp'), 'party');
|
| 338 |
+
installer.offer();
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
function drawFox(x, cx, cy, s) {
|
| 342 |
+
x.save(); x.translate(cx - 256 * s, cy - 256 * s); x.scale(s, s);
|
| 343 |
+
const P = (d, f) => { x.fillStyle = f; x.fill(new Path2D(d)); };
|
| 344 |
+
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');
|
| 345 |
+
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();
|
| 346 |
+
x.strokeStyle = '#1C1530'; x.lineWidth = 15; x.lineCap = 'round'; x.stroke(new Path2D('M178 284q24 18 48 0M286 284q24 18 48 0'));
|
| 347 |
+
x.restore();
|
| 348 |
+
}
|
| 349 |
+
function partyCard(players, history, names) {
|
| 350 |
+
const top = [...players].sort((a, b) => b.score - a.score).slice(0, 4);
|
| 351 |
+
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',
|
| 352 |
+
line: 'Can your group catch the Fox?', bg: '#1C1530', fg: '#FFF3E6', accent: '#FF7A2F', foot: 'cybermaxtools.com/games/hushfox' }, (x) => {
|
| 353 |
+
drawFox(x, 540, 470, 1.05);
|
| 354 |
+
x.font = '700 40px system-ui, sans-serif'; x.fillStyle = '#FFF3E6'; x.textAlign = 'center';
|
| 355 |
+
x.fillText(top.map((p) => `${p.name} ${p.score}`).join(' · '), 540, 790);
|
| 356 |
+
});
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// ------------------------------------------------------------------ room-code mode
|
| 360 |
+
|
| 361 |
+
let R = null; // { code, conn, s, me, error }
|
| 362 |
+
|
| 363 |
+
function roomHome(prefill = '') {
|
| 364 |
+
if (!navigator.onLine) {
|
| 365 |
+
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>`);
|
| 366 |
+
on('pp', () => passSetup()); on('back', home); return;
|
| 367 |
+
}
|
| 368 |
+
const me = store(LS_ME) || '';
|
| 369 |
+
show(`<p class="out">Room code</p><p class="lead">Each player joins on their own phone. Same room, real voices.</p>
|
| 370 |
+
<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>
|
| 371 |
+
<button class="btn go" id="create" type="button">➕ Create a room</button>
|
| 372 |
+
<p class="small muted2">or join one</p>
|
| 373 |
+
<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>
|
| 374 |
+
<button class="btn ghost" id="join" type="button" style="width:min(420px,100%)">Join room</button>
|
| 375 |
+
<p class="small" id="err" role="alert"></p><button class="lnk" id="back" type="button">Back</button>`, 'top');
|
| 376 |
+
const nm = () => { const v = C.cleanName($('me').value); store(LS_ME, v); return v; };
|
| 377 |
+
on('create', async () => {
|
| 378 |
+
const b = $('create'); b.disabled = true;
|
| 379 |
+
try { const code = await createRoom(); count(GAME, 'play', 'room-create'); joinRoom(code, nm()); }
|
| 380 |
+
catch { $('err').textContent = 'Could not create a room right now. Try again, or pass one phone.'; b.disabled = false; }
|
| 381 |
+
});
|
| 382 |
+
on('join', async () => {
|
| 383 |
+
const code = $('code').value.trim().toUpperCase();
|
| 384 |
+
if (!C.validCode(code)) { $('err').textContent = 'Codes are 4 letters, like KQBX.'; shake($('code')); return; }
|
| 385 |
+
try {
|
| 386 |
+
const i = await roomInfo(code);
|
| 387 |
+
if (!i.open) { $('err').textContent = 'No room with that code. Check the letters.'; shake($('code')); return; }
|
| 388 |
+
if (i.players >= C.MAX_PLAYERS) { $('err').textContent = 'That room is full (12 players).'; return; }
|
| 389 |
+
} catch { $('err').textContent = 'Could not reach the room. Check your connection.'; return; }
|
| 390 |
+
count(GAME, 'play', 'room-join');
|
| 391 |
+
joinRoom(code, nm());
|
| 392 |
+
});
|
| 393 |
+
on('back', home);
|
| 394 |
+
if (prefill) $('me').focus();
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
function joinRoom(code, name) {
|
| 398 |
+
leaveRoom();
|
| 399 |
+
R = { code, me: pid(), s: null, peek: false, voted: null, last: '' };
|
| 400 |
+
history.replaceState(null, '', `${location.pathname}?room=${code}${location.search.includes('api=') ? `&api=${encodeURIComponent(new URLSearchParams(location.search).get('api'))}` : ''}`);
|
| 401 |
+
show(`<p class="code-big">${esc(code)}</p><p class="lead">Joining…</p>`);
|
| 402 |
+
R.conn = connect(code, R.me, name, {
|
| 403 |
+
state: (s) => { if (!R) return; R.s = s; renderRoom(); },
|
| 404 |
+
error: (e) => { toast(e, 3000); },
|
| 405 |
+
status: (st) => {
|
| 406 |
+
if (!R) return;
|
| 407 |
+
if (st === 'gone') { const c = R.code; leaveRoom(); toast('You left the room.'); roomHome(c); return; }
|
| 408 |
+
let n = stage.querySelector('.net');
|
| 409 |
+
if (st === 'retry') { if (!n) { n = document.createElement('div'); n.className = 'net'; stage.appendChild(n); } n.textContent = 'Reconnecting…'; }
|
| 410 |
+
else if (n) n.remove();
|
| 411 |
+
},
|
| 412 |
+
});
|
| 413 |
+
}
|
| 414 |
+
function leaveRoom() {
|
| 415 |
+
if (!R) return;
|
| 416 |
+
try { R.conn.send({ t: 'leave' }); } catch { /* */ }
|
| 417 |
+
setTimeout(((c) => () => c.close())(R.conn), 150);
|
| 418 |
+
R = null;
|
| 419 |
+
if (location.search.includes('room=')) history.replaceState(null, '', location.pathname);
|
| 420 |
+
}
|
| 421 |
+
const rsend = (o) => R && R.conn.send(o);
|
| 422 |
+
const rname = (id) => (R.s.players.find((p) => p.id === id) || {}).name || '?';
|
| 423 |
+
|
| 424 |
+
function renderRoom() {
|
| 425 |
+
const s = R.s, me = s.players.find((p) => p.id === R.me), host = s.host === R.me;
|
| 426 |
+
const key = `${s.phase}|${s.round}|${s.game}`;
|
| 427 |
+
const fresh = key !== R.last;
|
| 428 |
+
if (fresh) { R.peek = false; R.voted = null; }
|
| 429 |
+
R.last = key;
|
| 430 |
+
const local = (t) => t - (s.offset || 0); // server time -> this phone's clock
|
| 431 |
+
if (me && me.waiting && ['reveal', 'clue', 'vote', 'guess', 'result'].includes(s.phase)) {
|
| 432 |
+
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>`);
|
| 433 |
+
on('leave', () => { leaveRoom(); home(); });
|
| 434 |
+
return;
|
| 435 |
+
}
|
| 436 |
+
if (s.phase === 'lobby') return roomLobby(s, host);
|
| 437 |
+
if (s.phase === 'reveal' || s.phase === 'clue') {
|
| 438 |
+
if (!fresh && s.phase === 'clue' && $('card')) {
|
| 439 |
+
// same clue phase: only the twist/peek letter may have changed
|
| 440 |
+
if (s.cur.twist && $('twist') && $('twist').classList.contains('wait')) flipTwist(s.cur.twist, false);
|
| 441 |
+
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>`);
|
| 442 |
+
return;
|
| 443 |
+
}
|
| 444 |
+
const c = s.card;
|
| 445 |
+
show(`<p class="small muted2">Room ${esc(s.code)} · round ${s.round + 1} of ${s.rounds}</p>
|
| 446 |
+
${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>` : ''}
|
| 447 |
+
${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)}`}
|
| 448 |
+
${host ? '<button class="btn ghost sm" id="skip" type="button">Skip ▶</button>' : ''}`);
|
| 449 |
+
const card = $('card');
|
| 450 |
+
if (card) card.addEventListener('click', () => { R.peek = !R.peek; card.classList.toggle('hidden', !R.peek); snd.tap(); buzz(15); });
|
| 451 |
+
if (fresh && s.phase === 'reveal') { snd.boss(); buzz(c && c.fox ? [30, 30, 30] : 30); }
|
| 452 |
+
if (fresh && s.phase === 'clue') snd.win();
|
| 453 |
+
const total = s.phase === 'reveal' ? C.REVEAL_MS : C.CLUE_MS;
|
| 454 |
+
runTimer(local(s.ends), total, s.phase === 'clue' ? { twistAt: local(s.twistAt), onTwist: () => {} } : {});
|
| 455 |
+
if (host) on('skip', () => rsend({ t: 'next' }));
|
| 456 |
+
return;
|
| 457 |
+
}
|
| 458 |
+
if (s.phase === 'vote') {
|
| 459 |
+
const voted = me && s.players.find((p) => p.id === R.me)?.voted;
|
| 460 |
+
const left = s.players.filter((p) => p.on && !p.waiting && !p.voted).length;
|
| 461 |
+
show(`<p class="out">Who's the Fox?</p><p class="cat" style="font-size:22px">${esc(s.cur.cat)}</p>${timerHtml()}
|
| 462 |
+
${voted ? `<p class="lead">✓ Vote in. Waiting for ${left} more…</p>` : '<p class="lead">Tap the player you think is the Fox.</p>'}
|
| 463 |
+
<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>`);
|
| 464 |
+
if (fresh) { snd.hit(); buzz(150); }
|
| 465 |
+
runTimer(local(s.ends), C.VOTE_MS);
|
| 466 |
+
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'); }));
|
| 467 |
+
return;
|
| 468 |
+
}
|
| 469 |
+
if (s.phase === 'guess') {
|
| 470 |
+
const isFox = s.cur.accused === R.me;
|
| 471 |
+
show(`${fox('sm')}<p class="out">${isFox ? 'You were caught!' : `${esc(rname(s.cur.accused))} is the Fox!`}</p>
|
| 472 |
+
${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()}`);
|
| 473 |
+
if (fresh) { snd.good(8); burstAt(stage.querySelector('.fox'), 0.5, 0.5); }
|
| 474 |
+
runTimer(local(s.ends), C.GUESS_MS);
|
| 475 |
+
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 }); }));
|
| 476 |
+
return;
|
| 477 |
+
}
|
| 478 |
+
if (s.phase === 'result') {
|
| 479 |
+
if (!fresh) return;
|
| 480 |
+
const c = s.cur;
|
| 481 |
+
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 },
|
| 482 |
+
{ next: () => rsend({ t: 'next' }), host });
|
| 483 |
+
return;
|
| 484 |
+
}
|
| 485 |
+
if (s.phase === 'final') {
|
| 486 |
+
if (!fresh && stage.querySelector('#share')) return;
|
| 487 |
+
const scores = Object.fromEntries(s.players.map((p) => [p.id, p.score]));
|
| 488 |
+
finalScreen({ players: s.players, history: s.history, winners: C.winners(scores) }, { rematch: () => rsend({ t: 'rematch' }), host });
|
| 489 |
+
}
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function roomLobby(s, host) {
|
| 493 |
+
const link = `${SITE}${PATH}?room=${s.code}`;
|
| 494 |
+
const active = s.players.filter((p) => p.on);
|
| 495 |
+
show(`<p class="small muted2">Room code</p><p class="code-big" id="rcode">${esc(s.code)}</p>
|
| 496 |
+
<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>
|
| 497 |
+
${s.note ? `<p class="banner">${esc(s.note)}</p>` : ''}
|
| 498 |
+
<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>
|
| 499 |
+
<p class="small muted2">${active.length}/${C.MAX_PLAYERS} players · need ${C.MIN_PLAYERS}+</p>
|
| 500 |
+
${host ? `<div class="field"><label>Rounds</label>${roundsHtml(s.rounds)}</div><div class="field"><label>Packs</label>${packsHtml(s.packs)}</div>
|
| 501 |
+
<button class="btn go" id="start" type="button" ${active.length < C.MIN_PLAYERS ? 'disabled' : ''}>▶ Start game</button>`
|
| 502 |
+
: `<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>`}
|
| 503 |
+
<button class="lnk" id="leave" type="button">Leave room</button>`, 'top');
|
| 504 |
+
on('invite', () => { share(GAME, `Join my Hushfox party game! Room ${s.code}`, `${PATH}?room=${s.code}`); });
|
| 505 |
+
on('copy', async () => { try { await navigator.clipboard.writeText(s.code); toast('Code copied'); } catch { prompt('Room code:', s.code); } });
|
| 506 |
+
on('leave', () => { leaveRoom(); home(); });
|
| 507 |
+
stage.querySelectorAll('[data-kick]').forEach((b) => b.addEventListener('click', () => rsend({ t: 'kick', target: b.dataset.kick })));
|
| 508 |
+
if (host) {
|
| 509 |
+
const passCode = () => (store('cmx-pro:fun-pass') || {}).code || '';
|
| 510 |
+
bindRounds((n) => rsend({ t: 'settings', rounds: n }));
|
| 511 |
+
const sel = s.packs.slice();
|
| 512 |
+
bindPacks(sel, (p) => rsend({ t: 'settings', packs: p, pass: passCode() }));
|
| 513 |
+
on('start', () => { rsend({ t: 'settings', packs: sel, pass: passCode(), extra: aiExtra }); setTimeout(() => rsend({ t: 'start' }), 120); });
|
| 514 |
+
}
|
| 515 |
+
void link;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
// ------------------------------------------------------------------ solo: Fox Hunt (daily + levels)
|
| 519 |
+
|
| 520 |
+
const HUNT_DECK = deck(['classic']);
|
| 521 |
+
const WORLDS = ['Meadow', 'Den', 'Moonlit woods'];
|
| 522 |
+
const LEVELS = Array.from({ length: 30 }, (_, i) => {
|
| 523 |
+
const n = i + 1, sp = C.levelSpec(n);
|
| 524 |
+
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;
|
| 525 |
+
return { n, name: `${sp.size} words${sp.hard ? ', sneaky' : ''}`, twist, boss: n % 10 === 0 };
|
| 526 |
+
});
|
| 527 |
+
const prog = progress(GAME, { isPro });
|
| 528 |
+
const dly = daily(GAME);
|
| 529 |
+
|
| 530 |
+
function huntHome() {
|
| 531 |
+
lvCss();
|
| 532 |
+
const done = dly.best() !== undefined;
|
| 533 |
+
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>
|
| 534 |
+
<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>
|
| 535 |
+
<button class="big" id="hl" type="button"><b>▶ Level ${prog.reached()}</b><span>${prog.cleared()}/30 levels · ${prog.totalStars()}/90 ⭐</span></button>
|
| 536 |
+
<section class="lvmap" aria-label="Levels"><div id="lvmap"></div></section>
|
| 537 |
+
<button class="lnk" id="back" type="button">Back</button>`, 'top');
|
| 538 |
+
on('hd', () => hunt({ daily: true }));
|
| 539 |
+
on('hl', () => hunt({ level: prog.reached() }));
|
| 540 |
+
on('back', home);
|
| 541 |
+
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.')) });
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
let H = null;
|
| 545 |
+
function hunt({ daily: isDaily = false, level = 0, vs = null } = {}) {
|
| 546 |
+
const spec = isDaily ? { size: 6, hard: true, puzzles: 5, ms: C.CLUE_MS } : C.levelSpec(level);
|
| 547 |
+
if (level) { const lock = prog.lock(level, { challenge: !!vs }); if (lock === 'pass') { pass.open(`level-${level}`); return huntHome(); } prog.open(level); }
|
| 548 |
+
const set = C.huntSet(HUNT_DECK, isDaily ? `hunt-${dly.date}` : `hunt-L${level}`, spec);
|
| 549 |
+
H = { isDaily, level, spec, set, i: 0, marks: [], ends: Date.now() + spec.ms, vs, over: false };
|
| 550 |
+
count(GAME, 'play', isDaily ? 'hunt-daily' : `hunt-l${level}`);
|
| 551 |
+
huntPuzzle();
|
| 552 |
+
const step = () => {
|
| 553 |
+
const left = Math.max(0, H.ends - Date.now());
|
| 554 |
+
const bar = stage.querySelector('.hunt-bar i');
|
| 555 |
+
if (bar) bar.style.width = `${(left / spec.ms) * 100}%`;
|
| 556 |
+
if (left <= 0 && !H.over) huntEnd();
|
| 557 |
+
};
|
| 558 |
+
clearInterval(huntTick);
|
| 559 |
+
huntTick = setInterval(step, 150);
|
| 560 |
+
}
|
| 561 |
+
let huntTick = 0;
|
| 562 |
+
function huntPuzzle() {
|
| 563 |
+
const p = H.set[H.i];
|
| 564 |
+
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>
|
| 565 |
+
<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>
|
| 566 |
+
<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>`;
|
| 567 |
+
stage.querySelectorAll('[data-h]').forEach((b) => b.addEventListener('click', () => {
|
| 568 |
+
if (H.over || H.lock) return;
|
| 569 |
+
H.lock = true;
|
| 570 |
+
const ok = b.dataset.h === p.fox;
|
| 571 |
+
H.marks[H.i] = ok;
|
| 572 |
+
b.classList.add(ok ? 'good' : 'bad');
|
| 573 |
+
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'); }
|
| 574 |
+
const note = document.createElement('p'); note.className = 'small muted2'; note.textContent = `${p.fox} is from "${p.foxCat}"; the rest are ${p.cat}.`;
|
| 575 |
+
stage.firstElementChild.appendChild(note);
|
| 576 |
+
setTimeout(() => { H.lock = false; H.i++; if (H.i >= H.spec.puzzles) huntEnd(); else if (!H.over) huntPuzzle(); }, ok ? 650 : 1300);
|
| 577 |
+
}));
|
| 578 |
+
}
|
| 579 |
+
function huntEnd() {
|
| 580 |
+
if (H.over) return;
|
| 581 |
+
H.over = true;
|
| 582 |
+
clearInterval(huntTick);
|
| 583 |
+
const correct = H.marks.filter(Boolean).length;
|
| 584 |
+
const msLeft = Math.max(0, H.ends - Date.now());
|
| 585 |
+
const score = C.huntScore(correct, msLeft, H.spec.puzzles);
|
| 586 |
+
let head = '', stars = 0, rec = null;
|
| 587 |
+
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}`; }
|
| 588 |
+
updateUnder();
|
| 589 |
+
const grid = H.set.map((_, k) => (H.marks[k] ? '🦊' : H.marks[k] === false ? '❌' : '⬜')).join('');
|
| 590 |
+
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?`) : '';
|
| 591 |
+
const canNext = !H.isDaily && stars > 0 && H.level < 30;
|
| 592 |
+
show(`<p class="out">${head}: ${correct}/5 foxes</p>${H.isDaily ? '' : `<div class="lv-stars">${starsHtml(stars)}</div>`}
|
| 593 |
+
<p class="code-big" style="font-size:clamp(46px,14vw,72px);letter-spacing:0">${score}</p><p class="emoji">${grid}</p>
|
| 594 |
+
${vs ? `<p class="banner">${esc(vs)}</p>` : ''}${H.isDaily ? `<p class="small muted2">🔥 streak ${rec.streak} · best today ${rec.best}</p>` : ''}
|
| 595 |
+
<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>
|
| 596 |
+
<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>`);
|
| 597 |
+
if (correct >= 4) snd.win(); else snd.lose();
|
| 598 |
+
if (stars) [0, 1, 2].slice(0, stars).forEach((i) => setTimeout(() => snd.star(i), 200 + i * 140));
|
| 599 |
+
const text = `Hushfox Fox Hunt ${H.isDaily ? `#${dly.num}` : `level ${H.level}`}: ${score}\n${grid}`;
|
| 600 |
+
const link = challengeLink(PATH, GAME, H.isDaily ? { date: dly.date, score } : { level: H.level, score });
|
| 601 |
+
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));
|
| 602 |
+
const again = { daily: H.isDaily, level: H.level };
|
| 603 |
+
on('re', () => hunt(again));
|
| 604 |
+
if (canNext) on('nx', () => hunt({ level: H.level + 1 }));
|
| 605 |
+
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'); });
|
| 606 |
+
on('hcard', async () => { download(await canvasBlob(card()), 'hushfox-hunt.png'); count(GAME, 'share', 'card'); });
|
| 607 |
+
on('hm', huntHome);
|
| 608 |
+
count(GAME, 'win', H.isDaily ? 'hunt-daily' : 'hunt-level');
|
| 609 |
+
if (correct >= 3) pass.afterWin($('fp'), 'hunt');
|
| 610 |
+
installer.offer();
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
function updateUnder() {
|
| 614 |
+
const b = dly.best();
|
| 615 |
+
$('best').textContent = b === undefined ? '-' : b;
|
| 616 |
+
$('streak').textContent = dly.streak();
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
// ------------------------------------------------------------------ boot
|
| 620 |
+
|
| 621 |
+
const installer = pwa(GAME, $('install'));
|
| 622 |
+
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'],
|
| 623 |
+
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.' });
|
| 624 |
+
pass.onChange(() => { /* packs re-render on the next screen */ });
|
| 625 |
+
on('pass-btn', () => pass.open('button'));
|
| 626 |
+
themeToggle($('theme'));
|
| 627 |
+
const mb = $('mute');
|
| 628 |
+
if (mb) { mb.textContent = muted ? '🔇' : '🔊'; mb.addEventListener('click', () => { muted = !muted; store(`${GAME}:muted`, muted); mb.textContent = muted ? '🔇' : '🔊'; }); }
|
| 629 |
+
$('num').textContent = `#${dayNumber(today())}`;
|
| 630 |
+
updateUnder();
|
| 631 |
+
window.addEventListener('beforeunload', () => { if (R) try { R.conn.send({ t: 'leave' }); } catch { /* */ } });
|
| 632 |
+
|
| 633 |
+
(function route() {
|
| 634 |
+
const q = new URLSearchParams(location.search);
|
| 635 |
+
const room = (q.get('room') || '').toUpperCase();
|
| 636 |
+
const ch = readChallenge();
|
| 637 |
+
if (room && C.validCode(room)) {
|
| 638 |
+
roomHome(room);
|
| 639 |
+
return;
|
| 640 |
+
}
|
| 641 |
+
if (ch) {
|
| 642 |
+
if (ch.level) return hunt({ level: Math.min(30, ch.level), vs: ch.score });
|
| 643 |
+
if (ch.date === dly.date) return hunt({ daily: true, vs: ch.score });
|
| 644 |
+
}
|
| 645 |
+
home();
|
| 646 |
+
})();
|
| 647 |
+
|
| 648 |
+
// test + studio hooks (no effect for players)
|
| 649 |
+
window.__cmxTest = { C, G: () => G, R: () => R, H: () => H, prog, dly, home, passSetup, huntHome, deck };
|
| 650 |
+
window.__cmxShot = () => { newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 5, ['classic'], false); passClues(); flipTwist(G.cur.twist, true); muted = true; };
|
| 651 |
+
window.__cmxDemo = async () => { // a real round on the real screens, sped up for the demo video (~11 s)
|
| 652 |
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
| 653 |
+
newPassGame(['Ana', 'Ben', 'Cy', 'Di'], 3, ['classic'], false);
|
| 654 |
+
await wait(900); $('see').click(); await wait(1500);
|
| 655 |
+
const fi = G.players.findIndex((p) => p.id === G.cur.fox);
|
| 656 |
+
if (fi !== 0) { cardFor(fi); await wait(1500); }
|
| 657 |
+
passClues(); await wait(1300); flipTwist(G.cur.twist, false); await wait(1900);
|
| 658 |
+
passVote(); await wait(300); $('count').click(); await wait(2300);
|
| 659 |
+
stage.querySelector(`[data-acc="${G.cur.fox}"]`).click(); await wait(1400);
|
| 660 |
+
stage.querySelector(`[data-w="${CSS.escape(G.cur.word)}"]`).click(); await wait(1600);
|
| 661 |
+
};
|
hushfox/icon-192.png
ADDED
|
|
hushfox/icon-512.png
ADDED
|
|
hushfox/icon-maskable.png
ADDED
|
|
hushfox/icon.svg
ADDED
|
|
hushfox/index.html
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
| 3 |
+
<title>Hushfox: Free Party Game for 3-12 Players, Find the Fox, No Download</title>
|
| 4 |
+
<meta name="description" content="Hushfox: free party game for 3-12 players. Everyone gets the word but the Fox. Pass one phone or join by room code. 30-second rounds, no sign-up, works offline.">
|
| 5 |
+
<link rel="canonical" href="https://cybermaxtools.com/games/hushfox/">
|
| 6 |
+
<meta name="theme-color" content="#1C1530">
|
| 7 |
+
<link rel="manifest" href="manifest.webmanifest">
|
| 8 |
+
<link rel="icon" href="icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icon-192.png">
|
| 9 |
+
<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Hushfox">
|
| 10 |
+
<link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
|
| 11 |
+
<script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
| 12 |
+
<meta property="og:title" content="Hushfox: Free Party Game for 3-12 Players">
|
| 13 |
+
<meta property="og:description" content="Hushfox: free party game for 3-12 players. Everyone gets the word but the Fox. Pass one phone or join by room code. 30-second rounds, no sign-up, works offline.">
|
| 14 |
+
<meta property="og:url" content="https://cybermaxtools.com/games/hushfox/">
|
| 15 |
+
<meta property="og:image" content="https://cybermaxtools.com/games/hushfox/og.png">
|
| 16 |
+
<meta property="og:type" content="website">
|
| 17 |
+
<meta name="twitter:card" content="summary_large_image">
|
| 18 |
+
<meta name="twitter:title" content="Hushfox: Free Party Game for 3-12 Players">
|
| 19 |
+
<meta name="twitter:description" content="Hushfox: free party game for 3-12 players. Everyone gets the word but the Fox. Pass one phone or join by room code. 30-second rounds, no sign-up, works offline.">
|
| 20 |
+
<meta name="twitter:image" content="https://cybermaxtools.com/games/hushfox/og.png">
|
| 21 |
+
<style>.h1kw{display:block;font-size:.42em;font-weight:600;opacity:.75;letter-spacing:.01em;margin-top:.15em}.seo ol,.seo ul{padding-left:1.2em}.seo li{margin:.25em 0}</style>
|
| 22 |
+
<script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/hushfox/#game", "name": "Hushfox", "alternateName": "Party game online free", "url": "https://cybermaxtools.com/games/hushfox/", "image": "https://cybermaxtools.com/games/hushfox/og.png", "description": "Hushfox is a free party game for 3 to 12 players in the same room. Everyone secretly sees the same word, except one player: the Fox, who only sees the category. Go round giving one clue each, and at the 15-second mark a Twist card flips (mime it, one-syllable clues, opposites, a sound instead of a word) for a second lap. When the 30 seconds are up, point at the Fox. A caught Fox can still steal the round by picking the word from six. Play on one phone passed around the table (works offline) or let everyone join on their own phone with a 4-letter room code. Over 3,400 hand-written family-safe words in the free deck, a daily deck that is the same for everyone, and a solo Fox Hunt with 30 levels.", "genre": ["Party game", "Word game", "Social deduction", "Hidden role"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": [{"@type": "Offer", "name": "Free demo (daily challenge + levels 1-20)", "price": "0", "priceCurrency": "USD"}], "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-09", "keywords": "Party game online free, free party word game for groups, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/hushfox/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/hushfox/#faq", "mainEntity": [{"@type": "Question", "name": "Is Hushfox free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. Pass-the-phone, room codes, the Classic deck, the daily deck and Fox Hunt levels 1-20 are free with no ads. The optional CyberMax Fun Pass adds 8 extra packs and levels 21-30."}}, {"@type": "Question", "name": "How many players do I need?", "acceptedAnswer": {"@type": "Answer", "text": "3 to 12 players in the same room. It is best with 4-8."}}, {"@type": "Question", "name": "Does everyone need a phone?", "acceptedAnswer": {"@type": "Answer", "text": "No. Pass one phone around the table, or use a room code if everyone has their own phone."}}, {"@type": "Question", "name": "Does it work offline?", "acceptedAnswer": {"@type": "Answer", "text": "Pass-the-phone and Fox Hunt work offline after your first visit. Room codes need an internet connection."}}, {"@type": "Question", "name": "Is it family friendly?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. Every word is hand-written and checked against a blocklist. The optional AI surprise category is filtered the same way. The Grown-ups night pack is about adult life (bills, dinner parties), never anything rude."}}, {"@type": "Question", "name": "Is it the same as other imposter games?", "acceptedAnswer": {"@type": "Answer", "text": "Hushfox has its own rules: a Twist card flips halfway through every round, and a caught Fox can steal the round by picking the word from six."}}, {"@type": "Question", "name": "Do you store anything about players?", "acceptedAnswer": {"@type": "Answer", "text": "No accounts or emails. A room keeps nicknames and scores only while it is open and deletes itself after 3 hours."}}]}, {"@type": "VideoObject", "name": "Hushfox demo: Find the Fox in 30 seconds", "description": "A 17-second screen recording of the live Hushfox page: Everyone sees the word, except the Fox; One clue each, then a Twist card flips; Caught? The Fox can still steal the word. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/hushfox/poster.jpg"], "uploadDate": "2026-10-09T01:28:19+00:00", "duration": "PT17S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/hushfox/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/hushfox/", "inLanguage": "en", "publisher": {"@type": "Organization", "name": "CyberMax", "logo": {"@type": "ImageObject", "url": "https://cybermax.cybermax-tools.workers.dev/store/img/favicon.webp"}}, "@id": "https://cybermaxtools.com/games/hushfox/#video"}]}</script>
|
| 23 |
+
</head><body>
|
| 24 |
+
<div class="wrap">
|
| 25 |
+
<header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
|
| 26 |
+
<nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mute" type="button" aria-label="Sound on or off">🔊</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
|
| 27 |
+
<main>
|
| 28 |
+
<section class="game">
|
| 29 |
+
<div class="headline"><h1>Hushfox <span id="num" class="num"></span> <small class="h1kw">Free party word game for groups</small></h1>
|
| 30 |
+
<p class="muted tag">Everyone gets the secret word except the Fox. One clue each, a Twist at 15 seconds, then point at the Fox. 3-12 players, one phone or a room code.</p></div>
|
| 31 |
+
<div class="stage" id="stage" aria-live="polite">
|
| 32 |
+
<div class="scr"><p class="logo">HUSH<i>FOX</i></p><p class="lead">Loading the party…</p><noscript><p class="lead">Hushfox needs JavaScript turned on.</p></noscript></div>
|
| 33 |
+
</div>
|
| 34 |
+
<div class="row under"><span class="muted small">Fox Hunt daily best <b id="best">-</b> · 🔥 streak <b id="streak">0</b></span><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
|
| 35 |
+
</section>
|
| 36 |
+
<section class="seo" id="guide">
|
| 37 |
+
<h2>Hushfox: party game online free</h2>
|
| 38 |
+
<p>Hushfox is a free party game for 3 to 12 players in the same room. Everyone secretly sees the same word, except one player: the Fox, who only sees the category. Go round giving one clue each, and at the 15-second mark a Twist card flips (mime it, one-syllable clues, opposites, a sound instead of a word) for a second lap. When the 30 seconds are up, point at the Fox. A caught Fox can still steal the round by picking the word from six. Play on one phone passed around the table (works offline) or let everyone join on their own phone with a 4-letter room code. Over 3,400 hand-written family-safe words in the free deck, a daily deck that is the same for everyone, and a solo Fox Hunt with 30 levels.</p>
|
| 39 |
+
<h2>How to play Hushfox</h2>
|
| 40 |
+
<ol><li>Add 3-12 player names (or create a room and share the 4-letter code).</li><li>Pass the phone: each player privately sees the word, or learns they are the Fox.</li><li>Go round the table and give one short clue each. The Fox bluffs.</li><li>At 15 seconds a Twist card flips and changes the rules for the second lap.</li><li>At zero, count 3-2-1 and everyone points at the Fox.</li><li>If the Fox is caught, they get one chance to steal the round by picking the word from six.</li></ol>
|
| 41 |
+
<h3>Controls</h3>
|
| 42 |
+
<p><b>Phone and tablet:</b> Tap to see your card, tap to hide it and pass the phone on. In room mode, tap your card to peek and tap a name to vote.</p>
|
| 43 |
+
<p><b>Desktop, laptop and Chromebook:</b> Click through the same screens; one laptop works as the table's shared screen in pass-the-phone mode.</p>
|
| 44 |
+
<h2>Hushfox tips and strategy</h2>
|
| 45 |
+
<ul><li>As a word holder, give a clue only someone who knows the word would understand, not the obvious one.</li><li>As the Fox, listen to the first clues and stay vague: a colour, a feeling or a place works for many words.</li><li>Watch who hesitates when the Twist card flips: the Fox has to improvise twice.</li><li>A split vote lets the Fox escape, so talk before you point.</li><li>Play the daily deck with friends in other cities and compare your result lines.</li></ul>
|
| 46 |
+
<h2>Game modes</h2>
|
| 47 |
+
<ul><li>Pass one phone (free, offline): 3-12 players, 3, 5 or 8 rounds, the free Classic deck of 145 categories.</li><li>Room code (free): everyone joins on their own phone; the host picks packs and rounds.</li><li>Daily deck: the same 5 words for everyone today.</li><li>Fox Hunt (solo): spot the word that doesn't belong, a daily puzzle and 30 levels with stars.</li><li>Fun Pass: 8 extra packs (Grown-ups night, Movie night, Office hours, Holidays, Español, Português, Deutsch, Français) and Fox Hunt levels 21-30.</li></ul>
|
| 48 |
+
<p>The daily challenge and levels 1-20 are free to play, with no ads and no sign-up. Later levels, bonus modes, the daily archive and colour themes come with the optional <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life), which opens everything in every CyberMax game.</p>
|
| 49 |
+
<h2>Hushfox FAQ</h2>
|
| 50 |
+
<h3>Is Hushfox free?</h3><p>Yes. Pass-the-phone, room codes, the Classic deck, the daily deck and Fox Hunt levels 1-20 are free with no ads. The optional CyberMax Fun Pass adds 8 extra packs and levels 21-30.</p>
|
| 51 |
+
<h3>How many players do I need?</h3><p>3 to 12 players in the same room. It is best with 4-8.</p>
|
| 52 |
+
<h3>Does everyone need a phone?</h3><p>No. Pass one phone around the table, or use a room code if everyone has their own phone.</p>
|
| 53 |
+
<h3>Does it work offline?</h3><p>Pass-the-phone and Fox Hunt work offline after your first visit. Room codes need an internet connection.</p>
|
| 54 |
+
<h3>Is it family friendly?</h3><p>Yes. Every word is hand-written and checked against a blocklist. The optional AI surprise category is filtered the same way. The Grown-ups night pack is about adult life (bills, dinner parties), never anything rude.</p>
|
| 55 |
+
<h3>Is it the same as other imposter games?</h3><p>Hushfox has its own rules: a Twist card flips halfway through every round, and a caught Fox can steal the round by picking the word from six.</p>
|
| 56 |
+
<h3>Do you store anything about players?</h3><p>No accounts or emails. A room keeps nicknames and scores only while it is open and deletes itself after 3 hours.</p>
|
| 57 |
+
<h2>Similar free games</h2>
|
| 58 |
+
<div class="more"><a href="https://cybermaxtools.com/games/kenwren/"><b>Kenwren</b><span>How well do your friends know you?</span></a><a href="https://cybermaxtools.com/games/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/games/pawfoil/"><b>Pawfoil</b><span>Make your pet a holo trading card</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
|
| 59 |
+
</section>
|
| 60 |
+
</main>
|
| 61 |
+
<footer>© 2026 CyberMax · <a href="https://cybermaxtools.com/games/">Games</a> · <a href="https://cybermaxtools.com/toys/">Toys</a> · <a href="https://cybermaxtools.com/store/">Store</a> · <a href="https://cybermaxtools.com/privacy">Privacy</a> · <a href="https://cybermaxtools.com/terms">Terms</a></footer>
|
| 62 |
+
</div>
|
| 63 |
+
<script type="module" src="game.js"></script>
|
| 64 |
+
</body></html>
|
hushfox/levels.js
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax Fun kit: levels, daily challenge, streaks, "beat my score" links and game juice (copied into each game's
|
| 2 |
+
// web/ folder by scripts/games/sync_kit.py when the game has "levels": true in products/fun-pass/games.json).
|
| 3 |
+
// Everything is saved in this browser only (localStorage). No new assets: sounds are WebAudio blips, particles are
|
| 4 |
+
// drawn on one overlay canvas. Levels 1..free are free; later levels need the CyberMax Fun Pass, but a level the player
|
| 5 |
+
// has already opened is never locked again. (c) 2026 CyberMax. All rights reserved.
|
| 6 |
+
import { SITE, today, dayNumber, dateOf, store, rr } from './cmx.js';
|
| 7 |
+
|
| 8 |
+
export const TOTAL = 30;
|
| 9 |
+
export const FREE = 20;
|
| 10 |
+
|
| 11 |
+
// ------------------------------------------------------------------ progress (pure logic, testable in node)
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Level progress for one game. Higher score is better unless `lower` is set (e.g. taps or time).
|
| 15 |
+
* opts: { total, free, isPro: () => bool, lower }
|
| 16 |
+
*/
|
| 17 |
+
export function progress(game, opts = {}) {
|
| 18 |
+
const total = opts.total || TOTAL, free = opts.free ?? FREE, isPro = opts.isPro || (() => false), lower = !!opts.lower;
|
| 19 |
+
const KEY = `${game}:levels`;
|
| 20 |
+
const read = () => {
|
| 21 |
+
const d = store(KEY) || {};
|
| 22 |
+
return { stars: d.stars || {}, best: d.best || {}, opened: d.opened || [] };
|
| 23 |
+
};
|
| 24 |
+
const api = {
|
| 25 |
+
total, free, lower,
|
| 26 |
+
data: read,
|
| 27 |
+
stars: (n) => read().stars[n] || 0,
|
| 28 |
+
best: (n) => read().best[n],
|
| 29 |
+
totalStars: () => Object.values(read().stars).reduce((a, b) => a + b, 0),
|
| 30 |
+
cleared: () => Object.keys(read().stars).filter((k) => read().stars[k] > 0).length,
|
| 31 |
+
/** The furthest level the player may open: one past the highest cleared level. */
|
| 32 |
+
reached() {
|
| 33 |
+
const s = read().stars;
|
| 34 |
+
let hi = 0;
|
| 35 |
+
for (const k of Object.keys(s)) if (s[k] > 0) hi = Math.max(hi, Number(k));
|
| 36 |
+
return Math.min(total, hi + 1);
|
| 37 |
+
},
|
| 38 |
+
/** null when playable, else 'progress' (clear the level before) or 'pass' (Fun Pass level). */
|
| 39 |
+
lock(n, { challenge = false } = {}) {
|
| 40 |
+
if (!(n >= 1 && n <= total)) return 'progress';
|
| 41 |
+
const d = read();
|
| 42 |
+
if (d.opened.includes(n) || n <= free && challenge) return null;
|
| 43 |
+
if (n > api.reached() && !challenge) return 'progress';
|
| 44 |
+
if (n > free && !isPro()) return 'pass';
|
| 45 |
+
return null;
|
| 46 |
+
},
|
| 47 |
+
/** Remember that a level was opened, so it stays open even if the pass ends later. */
|
| 48 |
+
open(n) {
|
| 49 |
+
const d = read();
|
| 50 |
+
if (!d.opened.includes(n)) { d.opened.push(n); store(KEY, d); }
|
| 51 |
+
},
|
| 52 |
+
/** Save a finished level. Returns { stars, best, improved, first, next } (next = the newly reachable level or 0). */
|
| 53 |
+
record(n, stars, score) {
|
| 54 |
+
const d = read();
|
| 55 |
+
const before = api.reached();
|
| 56 |
+
const first = !(d.stars[n] > 0);
|
| 57 |
+
const prev = d.best[n];
|
| 58 |
+
const improved = prev === undefined || (lower ? score < prev : score > prev);
|
| 59 |
+
if (improved && stars > 0) d.best[n] = score;
|
| 60 |
+
if (stars > (d.stars[n] || 0)) d.stars[n] = stars;
|
| 61 |
+
store(KEY, d);
|
| 62 |
+
const after = api.reached();
|
| 63 |
+
return { stars: d.stars[n] || 0, best: d.best[n], improved: improved && stars > 0, first: first && stars > 0, next: after > before ? after : 0 };
|
| 64 |
+
},
|
| 65 |
+
};
|
| 66 |
+
return api;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/** Stars from a score and three thresholds [one, two, three] (for `lower`, smaller scores are better). */
|
| 70 |
+
export function starsFor(score, t, lower = false) {
|
| 71 |
+
let s = 0;
|
| 72 |
+
for (const v of t) if (lower ? score <= v : score >= v) s++;
|
| 73 |
+
return s;
|
| 74 |
+
}
|
| 75 |
+
export const starText = (n, of = 3) => '⭐'.repeat(n) + '☆'.repeat(Math.max(0, of - n));
|
| 76 |
+
|
| 77 |
+
// ------------------------------------------------------------------ daily challenge + streak
|
| 78 |
+
|
| 79 |
+
/** The daily challenge: one seed for everyone today. record() keeps the best score and the day streak. */
|
| 80 |
+
export function daily(game, { lower = false } = {}) {
|
| 81 |
+
const KEY = `${game}:daily`;
|
| 82 |
+
const iso = today();
|
| 83 |
+
const read = () => store(KEY) || { days: {}, streak: 0, bestStreak: 0 };
|
| 84 |
+
return {
|
| 85 |
+
seed: iso, num: dayNumber(iso), date: iso,
|
| 86 |
+
best: () => read().days[iso],
|
| 87 |
+
streak() {
|
| 88 |
+
const d = read();
|
| 89 |
+
return d.days[iso] !== undefined || d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? d.streak || 0 : 0;
|
| 90 |
+
},
|
| 91 |
+
bestStreak: () => read().bestStreak || 0,
|
| 92 |
+
played: () => Object.keys(read().days).length,
|
| 93 |
+
record(score) {
|
| 94 |
+
const d = read();
|
| 95 |
+
const prev = d.days[iso];
|
| 96 |
+
if (prev === undefined) {
|
| 97 |
+
d.streak = d.days[dateOf(dayNumber(iso) - 1)] !== undefined ? (d.streak || 0) + 1 : 1;
|
| 98 |
+
d.bestStreak = Math.max(d.bestStreak || 0, d.streak);
|
| 99 |
+
}
|
| 100 |
+
const improved = prev === undefined || (lower ? score < prev : score > prev);
|
| 101 |
+
if (improved) d.days[iso] = score;
|
| 102 |
+
store(KEY, d);
|
| 103 |
+
return { best: d.days[iso], improved, streak: d.streak };
|
| 104 |
+
},
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ------------------------------------------------------------------ "beat my score" links
|
| 109 |
+
|
| 110 |
+
/** Read ?seed=&score= from the page URL. seed is a YYYY-MM-DD daily or L<n> for level n. */
|
| 111 |
+
export function readChallenge(search = typeof location !== 'undefined' ? location.search : '') {
|
| 112 |
+
const q = new URLSearchParams(search);
|
| 113 |
+
const seed = (q.get('seed') || '').trim();
|
| 114 |
+
const score = Number(q.get('score'));
|
| 115 |
+
if (!seed) return null;
|
| 116 |
+
const out = { seed, score: Number.isFinite(score) && q.get('score') !== null && q.get('score') !== '' ? score : null };
|
| 117 |
+
const lv = /^L(\d{1,3})$/i.exec(seed);
|
| 118 |
+
if (lv) out.level = Number(lv[1]);
|
| 119 |
+
else if (/^\d{4}-\d{2}-\d{2}$/.test(seed) && dayNumber(seed) >= 1 && seed <= today()) out.date = seed;
|
| 120 |
+
else return null;
|
| 121 |
+
return out;
|
| 122 |
+
}
|
| 123 |
+
/** The link a player shares: opens the same level (or daily) with their score to beat. */
|
| 124 |
+
export function challengeLink(path, game, { level, date, score }) {
|
| 125 |
+
const u = new URL(SITE + path);
|
| 126 |
+
u.searchParams.set('seed', level ? `L${level}` : date);
|
| 127 |
+
if (score !== undefined && score !== null) u.searchParams.set('score', String(score));
|
| 128 |
+
u.searchParams.set('utm_source', 'challenge');
|
| 129 |
+
u.searchParams.set('utm_medium', 'share');
|
| 130 |
+
u.searchParams.set('utm_campaign', `game-${game}`);
|
| 131 |
+
return u.toString();
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/** Share text + link via the native sheet (with an optional PNG), else copy. Returns 'shared'|'copied'|'cancelled'. */
|
| 135 |
+
export async function shareChallenge(text, url, blob, filename) {
|
| 136 |
+
const full = `${text}\n${url}`;
|
| 137 |
+
if (typeof navigator !== 'undefined' && navigator.share) {
|
| 138 |
+
try {
|
| 139 |
+
const file = blob ? new File([blob], filename || 'score.png', { type: 'image/png' }) : null;
|
| 140 |
+
await navigator.share(file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full });
|
| 141 |
+
return 'shared';
|
| 142 |
+
} catch (e) { if (e && e.name === 'AbortError') return 'cancelled'; }
|
| 143 |
+
}
|
| 144 |
+
try { await navigator.clipboard.writeText(full); } catch { prompt('Copy your challenge link:', full); }
|
| 145 |
+
return 'copied';
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// ------------------------------------------------------------------ juice: sound, shake, particles, tutorial
|
| 149 |
+
|
| 150 |
+
let ac = null;
|
| 151 |
+
/** WebAudio blips. muted() is checked on every sound. */
|
| 152 |
+
export function sfx(muted = () => false) {
|
| 153 |
+
const tone = (f, d = 0.07, type = 'sine', vol = 0.07, slide = 1.5, at = 0) => {
|
| 154 |
+
if (muted()) return;
|
| 155 |
+
try {
|
| 156 |
+
ac = ac || new (window.AudioContext || window.webkitAudioContext)();
|
| 157 |
+
if (ac.state === 'suspended') ac.resume();
|
| 158 |
+
const t = ac.currentTime + at, o = ac.createOscillator(), v = ac.createGain();
|
| 159 |
+
o.type = type; o.frequency.setValueAtTime(f, t); o.frequency.exponentialRampToValueAtTime(Math.max(30, f * slide), t + d);
|
| 160 |
+
v.gain.setValueAtTime(vol, t); v.gain.exponentialRampToValueAtTime(0.0001, t + d);
|
| 161 |
+
o.connect(v); v.connect(ac.destination); o.start(t); o.stop(t + d + 0.02);
|
| 162 |
+
} catch { /* no audio */ }
|
| 163 |
+
};
|
| 164 |
+
return {
|
| 165 |
+
tone,
|
| 166 |
+
tap: () => tone(660, 0.04, 'triangle', 0.05, 1.3),
|
| 167 |
+
good: (k = 0) => tone(880 + Math.min(k, 30) * 22, 0.06, 'sine', 0.06, 1.4),
|
| 168 |
+
hit: () => { tone(160, 0.3, 'sawtooth', 0.11, 0.3); tone(90, 0.25, 'square', 0.05, 0.5, 0.02); },
|
| 169 |
+
lose: () => { tone(330, 0.16, 'triangle', 0.08, 0.7); tone(220, 0.3, 'triangle', 0.08, 0.6, 0.15); },
|
| 170 |
+
star: (i = 0) => tone(990 * (1 + i * 0.26), 0.12, 'sine', 0.07, 1.25, i * 0.14),
|
| 171 |
+
win: () => [523, 659, 784, 1046].forEach((f, i) => tone(f, 0.16, 'triangle', 0.07, 1.02, i * 0.09)),
|
| 172 |
+
boss: () => [196, 233, 262].forEach((f, i) => tone(f, 0.22, 'square', 0.04, 1, i * 0.12)),
|
| 173 |
+
};
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
const reduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
| 177 |
+
|
| 178 |
+
/** Shake an element for ms (skipped when the player prefers reduced motion). */
|
| 179 |
+
export function shake(el, mag = 8, ms = 260) {
|
| 180 |
+
if (!el || !el.animate || reduced()) return;
|
| 181 |
+
const k = [];
|
| 182 |
+
for (let i = 0; i <= 6; i++) { const m = mag * (1 - i / 6); k.push({ transform: `translate(${(i % 2 ? 1 : -1) * m}px, ${(i % 3 - 1) * m * 0.5}px)` }); }
|
| 183 |
+
el.animate(k, { duration: ms, easing: 'ease-out' });
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
let fx = null, parts = [], fxRaf = 0;
|
| 187 |
+
/** Particle burst at a page point (clientX/clientY). colors = array of CSS colours. */
|
| 188 |
+
export function burst(x, y, { colors = ['#FFC857', '#FF6A3D', '#5CF2FF', '#FFFFFF'], n = 26, speed = 6, life = 700 } = {}) {
|
| 189 |
+
if (typeof document === 'undefined' || reduced()) return;
|
| 190 |
+
if (!fx) {
|
| 191 |
+
fx = document.createElement('canvas');
|
| 192 |
+
fx.className = 'cmx-fx';
|
| 193 |
+
fx.setAttribute('aria-hidden', 'true');
|
| 194 |
+
document.body.appendChild(fx);
|
| 195 |
+
}
|
| 196 |
+
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
| 197 |
+
if (fx.width !== innerWidth * dpr || fx.height !== innerHeight * dpr) { fx.width = innerWidth * dpr; fx.height = innerHeight * dpr; }
|
| 198 |
+
const t0 = performance.now();
|
| 199 |
+
for (let i = 0; i < n; i++) {
|
| 200 |
+
const a = Math.random() * Math.PI * 2, v = speed * (0.4 + Math.random());
|
| 201 |
+
parts.push({ x: x * dpr, y: y * dpr, vx: Math.cos(a) * v * dpr, vy: Math.sin(a) * v * dpr - 2 * dpr, c: colors[i % colors.length], t0, life: life * (0.6 + Math.random() * 0.6), r: (2 + Math.random() * 3.5) * dpr });
|
| 202 |
+
}
|
| 203 |
+
if (!fxRaf) fxRaf = requestAnimationFrame(fxStep);
|
| 204 |
+
}
|
| 205 |
+
function fxStep(now) {
|
| 206 |
+
const c = fx.getContext('2d');
|
| 207 |
+
c.clearRect(0, 0, fx.width, fx.height);
|
| 208 |
+
parts = parts.filter((p) => now - p.t0 < p.life);
|
| 209 |
+
for (const p of parts) {
|
| 210 |
+
p.x += p.vx; p.y += p.vy; p.vy += 0.25; p.vx *= 0.98;
|
| 211 |
+
c.globalAlpha = 1 - (now - p.t0) / p.life;
|
| 212 |
+
c.fillStyle = p.c; c.fillRect(p.x - p.r / 2, p.y - p.r / 2, p.r, p.r);
|
| 213 |
+
}
|
| 214 |
+
c.globalAlpha = 1;
|
| 215 |
+
fxRaf = parts.length ? requestAnimationFrame(fxStep) : 0;
|
| 216 |
+
}
|
| 217 |
+
/** Burst from the centre of an element (or a point inside it given as 0..1 fractions). */
|
| 218 |
+
export function burstAt(el, fx = 0.5, fy = 0.5, opts) {
|
| 219 |
+
if (!el) return;
|
| 220 |
+
const r = el.getBoundingClientRect();
|
| 221 |
+
burst(r.left + r.width * fx, r.top + r.height * fy, opts);
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
/**
|
| 225 |
+
* The 2-second no-text tutorial: a pulsing hand over a spot of `el` (fractions fx, fy). gesture: 'tap' (default),
|
| 226 |
+
* 'hold' or 'swipe'. Shown once per game (key `<game>:tut`) unless force is set; any tap hides it.
|
| 227 |
+
*/
|
| 228 |
+
export function tutorial(game, el, { fx = 0.5, fy = 0.6, gesture = 'tap', force = false, ms = 2200 } = {}) {
|
| 229 |
+
if (!el || (!force && store(`${game}:tut`))) return;
|
| 230 |
+
store(`${game}:tut`, 1);
|
| 231 |
+
const host = el.parentElement;
|
| 232 |
+
if (getComputedStyle(host).position === 'static') host.style.position = 'relative';
|
| 233 |
+
const h = document.createElement('div');
|
| 234 |
+
h.className = `cmx-tut cmx-tut-${gesture}`;
|
| 235 |
+
h.setAttribute('aria-hidden', 'true');
|
| 236 |
+
h.innerHTML = '<i></i><b>👆</b>';
|
| 237 |
+
const r = el.getBoundingClientRect(), hr = host.getBoundingClientRect();
|
| 238 |
+
h.style.left = `${r.left - hr.left + r.width * fx}px`;
|
| 239 |
+
h.style.top = `${r.top - hr.top + r.height * fy}px`;
|
| 240 |
+
host.appendChild(h);
|
| 241 |
+
const off = () => { h.remove(); window.removeEventListener('pointerdown', off, true); };
|
| 242 |
+
setTimeout(off, ms);
|
| 243 |
+
window.addEventListener('pointerdown', off, true);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// ------------------------------------------------------------------ level-select map
|
| 247 |
+
|
| 248 |
+
/**
|
| 249 |
+
* Render the level map into `host`. levels = [{ n, name, twist?, boss? }] (twist = short text for the first level
|
| 250 |
+
* of a new mechanic). onPick(n) runs for a playable level; onLocked(n, reason) for a locked one.
|
| 251 |
+
*/
|
| 252 |
+
export function renderMap(host, prog, levels, { current = 0, onPick, onLocked, worlds = [] } = {}) {
|
| 253 |
+
css();
|
| 254 |
+
const d = prog.data();
|
| 255 |
+
const reached = prog.reached();
|
| 256 |
+
let html = `<div class="lv-head"><b>${prog.cleared()}/${prog.total}</b> levels · <b>${prog.totalStars()}/${prog.total * 3}</b> ⭐</div>`;
|
| 257 |
+
for (let w = 0; w < Math.ceil(levels.length / 10); w++) {
|
| 258 |
+
const set = levels.slice(w * 10, w * 10 + 10);
|
| 259 |
+
const twists = set.filter((l) => l.twist).map((l) => `<span>Lv ${l.n}: ${esc(l.twist)}</span>`).join('');
|
| 260 |
+
html += `<section class="lv-world"><h3>${esc(worlds[w] || `World ${w + 1}`)}</h3>${twists ? `<p class="lv-twists">${twists}</p>` : ''}<ol class="lv-grid">`;
|
| 261 |
+
set.forEach((l, i) => {
|
| 262 |
+
const lock = prog.lock(l.n);
|
| 263 |
+
const passLv = !!lock && prog.lock(l.n, { challenge: true }) === 'pass'; // Fun Pass level: show the lock even before it is reached
|
| 264 |
+
const st = d.stars[l.n] || 0;
|
| 265 |
+
const cls = ['lv', l.boss ? 'boss' : '', lock ? `locked ${lock}` : '', st ? 'done' : '', l.n === reached && !st ? 'next' : '', l.n === current ? 'cur' : '', Math.floor(i / 5) % 2 ? 'rev' : ''].filter(Boolean).join(' ');
|
| 266 |
+
const label = `Level ${l.n}${l.name ? `: ${l.name}` : ''}${l.boss ? ' (challenge level)' : ''}${passLv ? ', Fun Pass' : lock ? ', locked' : st ? `, ${st} of 3 stars` : ''}`;
|
| 267 |
+
html += `<li><button type="button" class="${cls}" data-lv="${l.n}" aria-label="${esc(label)}" title="${esc(label)}">${l.boss ? '<i class="crown">👑</i>' : ''}<span class="n">${passLv ? '🔒' : l.n}</span><span class="st">${lock === 'progress' ? '' : starText(st).replace(/☆/g, '<s>☆</s>')}</span>${l.twist ? '<i class="new">NEW</i>' : ''}</button></li>`;
|
| 268 |
+
});
|
| 269 |
+
html += '</ol></section>';
|
| 270 |
+
}
|
| 271 |
+
if (prog.free < prog.total) html += `<p class="lv-note">Levels 1-${prog.free} are free. Levels ${prog.free + 1}-${prog.total} come with the CyberMax Fun Pass; a level you have opened always stays open.</p>`;
|
| 272 |
+
host.innerHTML = html;
|
| 273 |
+
host.querySelectorAll('[data-lv]').forEach((b) => b.addEventListener('click', () => {
|
| 274 |
+
const n = Number(b.dataset.lv), lock = prog.lock(n);
|
| 275 |
+
if (lock) onLocked && onLocked(n, lock); else onPick && onPick(n);
|
| 276 |
+
}));
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
| 280 |
+
|
| 281 |
+
// ------------------------------------------------------------------ score card (1080×1350 PNG for stories)
|
| 282 |
+
|
| 283 |
+
/** A share card: { title, sub, big, unit, line, stars, bg, fg, accent, foot } -> canvas. draw(ctx) adds game art. */
|
| 284 |
+
export function scoreCard(o, draw) {
|
| 285 |
+
const c = document.createElement('canvas');
|
| 286 |
+
c.width = 1080; c.height = 1350;
|
| 287 |
+
const x = c.getContext('2d');
|
| 288 |
+
x.fillStyle = o.bg || '#11141B'; x.fillRect(0, 0, 1080, 1350);
|
| 289 |
+
if (draw) { x.save(); draw(x); x.restore(); }
|
| 290 |
+
x.textAlign = 'left'; x.fillStyle = o.accent || '#FF6A3D'; x.font = '900 64px system-ui, sans-serif'; x.fillText(String(o.title || '').toUpperCase(), 80, 130);
|
| 291 |
+
x.fillStyle = o.fg || '#fff'; x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif'; x.fillText(o.sub || '', 80, 190); x.globalAlpha = 1;
|
| 292 |
+
x.fillStyle = 'rgba(0,0,0,.35)'; rr(x, 60, 930, 960, 300, 36); x.fill();
|
| 293 |
+
x.textAlign = 'center'; x.fillStyle = o.fg || '#fff';
|
| 294 |
+
x.font = '900 150px system-ui, sans-serif'; x.fillText(String(o.big ?? ''), 540, 1080);
|
| 295 |
+
x.font = '700 44px system-ui, sans-serif'; x.fillText(o.unit || '', 540, 1140);
|
| 296 |
+
if (o.stars !== undefined) { x.font = '64px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; x.fillText(starText(o.stars), 540, 1205); }
|
| 297 |
+
x.fillStyle = o.accent || '#FF6A3D'; x.font = '800 46px system-ui, sans-serif'; x.fillText(o.line || 'Can you beat it?', 540, 880);
|
| 298 |
+
x.fillStyle = o.fg || '#fff'; x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText(o.foot || 'cybermaxtools.com/games', 540, 1300);
|
| 299 |
+
return c;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// ------------------------------------------------------------------ styles (injected once; no extra file)
|
| 303 |
+
|
| 304 |
+
let cssDone = false;
|
| 305 |
+
export function css() {
|
| 306 |
+
if (cssDone || typeof document === 'undefined') return;
|
| 307 |
+
cssDone = true;
|
| 308 |
+
const s = document.createElement('style');
|
| 309 |
+
s.textContent = `.cmx-fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:60}
|
| 310 |
+
.lv-head{font-size:15px;color:var(--muted);margin:4px 0 8px}.lv-head b{color:var(--ink)}
|
| 311 |
+
.lv-world h3{margin:14px 0 4px;font-size:16px}.lv-twists{margin:0 0 8px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 12px}
|
| 312 |
+
.lv-grid{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
|
| 313 |
+
.lv{position:relative;width:100%;aspect-ratio:1;max-width:72px;margin:0 auto;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:50%;border:2.5px solid var(--line);background:var(--card);color:var(--ink);font:inherit;font-weight:900;font-size:18px;cursor:pointer;touch-action:manipulation;padding:0}
|
| 314 |
+
.lv .n{line-height:1}.lv .st{font-size:9px;letter-spacing:-1px;line-height:1.2;min-height:11px}.lv .st s{text-decoration:none;opacity:.35}
|
| 315 |
+
.lv.done{border-color:var(--ok)}.lv.boss{border-radius:18px;border-width:3px}.lv.boss.done{border-color:#E0A400}
|
| 316 |
+
.lv.next{border-color:var(--acc);box-shadow:0 0 0 4px color-mix(in srgb,var(--acc) 25%,transparent);animation:lvp 1.4s ease-in-out infinite}
|
| 317 |
+
.lv.cur{outline:3px solid var(--ink);outline-offset:2px}
|
| 318 |
+
.lv.locked.progress{opacity:.38;cursor:default}.lv.locked.pass{opacity:.75;border-style:dashed}
|
| 319 |
+
.lv .crown{position:absolute;top:-10px;font-style:normal;font-size:16px}.lv .new{position:absolute;bottom:-8px;font-style:normal;font-size:9px;background:var(--acc);color:var(--acc-ink);padding:1px 5px;border-radius:6px;font-weight:900}
|
| 320 |
+
.lv-note{font-size:13px;color:var(--muted);margin:14px 0 0}
|
| 321 |
+
@keyframes lvp{50%{transform:scale(1.07)}}
|
| 322 |
+
.cmx-tut{position:absolute;width:0;height:0;pointer-events:none;z-index:20}
|
| 323 |
+
.cmx-tut i{position:absolute;left:-28px;top:-28px;width:56px;height:56px;border-radius:50%;border:4px solid #fff;opacity:0;animation:tutring 1s ease-out infinite}
|
| 324 |
+
.cmx-tut b{position:absolute;left:-18px;top:-2px;font-size:44px;filter:drop-shadow(0 3px 6px rgba(0,0,0,.5));animation:tuttap 1s ease-in-out infinite}
|
| 325 |
+
.cmx-tut-hold b{animation:tuthold 1.1s ease-in-out infinite}.cmx-tut-swipe b{animation:tutswipe 1.1s ease-in-out infinite}
|
| 326 |
+
@keyframes tutring{0%{transform:scale(.4);opacity:.9}100%{transform:scale(1.5);opacity:0}}
|
| 327 |
+
@keyframes tuttap{0%,100%{transform:translateY(8px)}50%{transform:translateY(0) scale(.9)}}
|
| 328 |
+
@keyframes tuthold{0%{transform:translateY(8px)}30%,80%{transform:translateY(0) scale(.85)}100%{transform:translateY(8px)}}
|
| 329 |
+
@keyframes tutswipe{0%{transform:translateX(-40px)}100%{transform:translateX(40px)}}
|
| 330 |
+
.lv-banner{display:flex;gap:8px;align-items:center;justify-content:space-between;flex-wrap:wrap;background:var(--card);border:1.5px solid var(--acc);border-radius:14px;padding:8px 12px;margin:8px 0;font-weight:700}
|
| 331 |
+
.lv-goal{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:15px;margin:8px 0}.lv-goal b{font-variant-numeric:tabular-nums}
|
| 332 |
+
.lv-stars{font-size:30px;letter-spacing:4px;margin:4px 0}.lv-stars span{display:inline-block;animation:lvstar .45s cubic-bezier(.2,1.6,.4,1) both}
|
| 333 |
+
@keyframes lvstar{0%{transform:scale(0) rotate(-40deg)}100%{transform:scale(1)}}
|
| 334 |
+
@media (prefers-reduced-motion:reduce){.lv.next,.cmx-tut i,.cmx-tut b,.lv-stars span{animation:none}}`;
|
| 335 |
+
document.head.appendChild(s);
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
/** Animated star row HTML for result panels. */
|
| 339 |
+
export function starsHtml(n) {
|
| 340 |
+
return [0, 1, 2].map((i) => `<span style="animation-delay:${i * 0.14}s${i < n ? '' : ';opacity:.3'}">${i < n ? '⭐' : '☆'}</span>`).join('');
|
| 341 |
+
}
|
hushfox/manifest.webmanifest
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Hushfox",
|
| 3 |
+
"short_name": "Hushfox",
|
| 4 |
+
"description": "Party game for 3-12: everyone knows the word except the Fox. Free daily game by CyberMax, works offline.",
|
| 5 |
+
"id": "index.html",
|
| 6 |
+
"start_url": "index.html?utm_source=pwa-hf",
|
| 7 |
+
"scope": "./",
|
| 8 |
+
"display": "standalone",
|
| 9 |
+
"orientation": "portrait",
|
| 10 |
+
"background_color": "#1C1530",
|
| 11 |
+
"theme_color": "#1C1530",
|
| 12 |
+
"categories": [
|
| 13 |
+
"games",
|
| 14 |
+
"entertainment"
|
| 15 |
+
],
|
| 16 |
+
"icons": [
|
| 17 |
+
{
|
| 18 |
+
"src": "icon-192.png",
|
| 19 |
+
"sizes": "192x192",
|
| 20 |
+
"type": "image/png"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"src": "icon-512.png",
|
| 24 |
+
"sizes": "512x512",
|
| 25 |
+
"type": "image/png"
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"src": "icon-maskable.png",
|
| 29 |
+
"sizes": "512x512",
|
| 30 |
+
"type": "image/png",
|
| 31 |
+
"purpose": "maskable"
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"src": "icon.svg",
|
| 35 |
+
"sizes": "any",
|
| 36 |
+
"type": "image/svg+xml"
|
| 37 |
+
}
|
| 38 |
+
],
|
| 39 |
+
"screenshots": [
|
| 40 |
+
{
|
| 41 |
+
"src": "shot-phone.png",
|
| 42 |
+
"sizes": "780x1688",
|
| 43 |
+
"type": "image/png",
|
| 44 |
+
"form_factor": "narrow"
|
| 45 |
+
}
|
| 46 |
+
]
|
| 47 |
+
}
|
hushfox/net.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hushfox room client: one WebSocket to the room's Durable Object, auto-reconnect with the same player id (the seat
|
| 2 |
+
// and score survive a locked phone for 15 s), server clock offset for the shared timer. (c) 2026 CyberMax.
|
| 3 |
+
export const API_DEFAULT = 'https://hushfox.cybermaxtools.com';
|
| 4 |
+
|
| 5 |
+
/** The rooms API base. Local test pages may pass ?api=http://127.0.0.1:PORT (only honoured on localhost). */
|
| 6 |
+
export function apiBase() {
|
| 7 |
+
try {
|
| 8 |
+
const q = new URLSearchParams(location.search).get('api');
|
| 9 |
+
if (q && /^(localhost|127\.0\.0\.1)$/.test(location.hostname) && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/.test(q)) return q;
|
| 10 |
+
} catch { /* */ }
|
| 11 |
+
return window.HUSHFOX_API || API_DEFAULT;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export async function createRoom(api = apiBase()) {
|
| 15 |
+
const r = await fetch(`${api}/api/rooms`, { method: 'POST' });
|
| 16 |
+
if (!r.ok) throw new Error('Could not create a room');
|
| 17 |
+
return (await r.json()).code;
|
| 18 |
+
}
|
| 19 |
+
export async function roomInfo(code, api = apiBase()) {
|
| 20 |
+
const r = await fetch(`${api}/api/rooms/${encodeURIComponent(code)}`);
|
| 21 |
+
return r.json();
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** Connect to a room. handlers: { state(s), error(msg), status('open'|'retry'|'gone') }. Returns { send, close }. */
|
| 25 |
+
export function connect(code, pid, name, handlers, api = apiBase()) {
|
| 26 |
+
let ws = null, closed = false, tries = 0, timer = 0, ping = 0;
|
| 27 |
+
const open = () => {
|
| 28 |
+
ws = new WebSocket(`${api.replace(/^http/, 'ws')}/ws/${code}`);
|
| 29 |
+
ws.addEventListener('open', () => {
|
| 30 |
+
tries = 0;
|
| 31 |
+
ws.send(JSON.stringify({ t: 'hello', pid, name }));
|
| 32 |
+
handlers.status && handlers.status('open');
|
| 33 |
+
clearInterval(ping);
|
| 34 |
+
ping = setInterval(() => { try { ws.send('{"t":"ping"}'); } catch { /* */ } }, 25000);
|
| 35 |
+
});
|
| 36 |
+
ws.addEventListener('message', (e) => {
|
| 37 |
+
let m; try { m = JSON.parse(e.data); } catch { return; }
|
| 38 |
+
if (m.t === 'state') { m.s.offset = m.s.now - Date.now(); handlers.state(m.s); }
|
| 39 |
+
else if (m.t === 'error') handlers.error && handlers.error(m.error);
|
| 40 |
+
else if (m.t === 'gone') { closed = true; handlers.status && handlers.status('gone'); }
|
| 41 |
+
});
|
| 42 |
+
ws.addEventListener('close', (e) => {
|
| 43 |
+
clearInterval(ping);
|
| 44 |
+
if (closed || e.code === 4000 || e.code === 4001) { if (e.code !== 4000) handlers.status && handlers.status('gone'); return; }
|
| 45 |
+
handlers.status && handlers.status('retry');
|
| 46 |
+
tries++;
|
| 47 |
+
timer = setTimeout(open, Math.min(8000, 400 * 2 ** Math.min(tries, 5)));
|
| 48 |
+
});
|
| 49 |
+
};
|
| 50 |
+
open();
|
| 51 |
+
return {
|
| 52 |
+
send(o) { if (ws && ws.readyState === 1) { ws.send(JSON.stringify(o)); return true; } return false; },
|
| 53 |
+
close() { closed = true; clearTimeout(timer); clearInterval(ping); try { ws.close(); } catch { /* */ } },
|
| 54 |
+
};
|
| 55 |
+
}
|
hushfox/offer.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Written by scripts/games/sync_kit.py from products/fun-pass/games.json; do not edit by hand. (c) 2026 CyberMax.
|
| 2 |
+
export const OFFER = {"game": "hushfox", "name": "Hushfox", "price": "$3.99", "live": false, "demo": 20, "games": 10};
|
hushfox/og.png
ADDED
|
Git LFS Details
|
hushfox/packs.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hushfox packs: the free Classic deck (words.js) plus the Fun Pass packs below. Every pack is hand-written and
|
| 2 |
+
// family-safe ("Grown-ups night" is about grown-up life, never anything rude). Same format as words.js.
|
| 3 |
+
// Packs marked pass: true need the CyberMax Fun Pass (funpass.js isPro(), the same check as every CyberMax game).
|
| 4 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 5 |
+
import { CORE } from './words.js';
|
| 6 |
+
|
| 7 |
+
export const PACKS = [
|
| 8 |
+
{ id: 'classic', name: 'Classic', emoji: '🦊', lang: 'en', pass: false, desc: 'Food, animals, places, things: the free everyday deck', cats: CORE },
|
| 9 |
+
{ id: 'grownups', name: 'Grown-ups night', emoji: '🍷', lang: 'en', pass: true, desc: 'Bills, dates, chores and dinner parties: grown-up life, still clean', cats: [
|
| 10 |
+
['Things at a dinner party', 'adult', 'Candles|Cheese board|Wine glass|Place cards|Playlist|Host gift|Dessert|Small talk|Napkin ring|Second helping|Dishwasher|Board game|Coat pile|Toast|Seating plan|Late taxi|Leftovers|Coffee|Tablecloth|Salad bowl'],
|
| 11 |
+
['First date spots', 'adult', 'Coffee shop|Bowling alley|Museum|Picnic|Cinema|Mini golf|Wine bar|Food market|Arcade|Cooking class|Botanical garden|Bookshop|Karaoke|Ice rink|Rooftop bar|Pottery class|Pier|Comedy club|Ramen place|Escape room'],
|
| 12 |
+
['Adulting chores', 'adult', 'Taxes|Laundry|Dishes|Grocery run|Oil change|Dentist booking|Budget|Recycling|Ironing|Meal prep|Returning a parcel|Renewing a passport|Vacuuming|Paying rent|Calling the bank|Cleaning the oven|Watering plants|Changing sheets|Fixing a tap|Reading the meter'],
|
| 13 |
+
['Things in a group chat', 'adult', 'Meme|Voice note|Poll|Birthday reminder|Thumbs up|Typing dots|Muted thread|Photo dump|Plans that fall through|Inside joke|Sticker|Read receipt|Pinned message|Late reply|Link|GIF|Screenshot|Calendar invite|Location pin|Emoji reaction'],
|
| 14 |
+
['Wedding guest life', 'adult', 'Plus one|Gift registry|Seating chart|Speech|Dance floor|Open bar|Photo booth|Dress code|Hotel block|Confetti|Kids table|Late-night snack|Farewell brunch|Shoe blister|Bouquet toss|Group photo|Thank you card|Save the date|RSVP|Cake cutting'],
|
| 15 |
+
['Things at a garage sale', 'adult', 'Price sticker|Old lamp|Board game|Vinyl record|Bread maker|Exercise bike|Picture frame|Cash box|Haggling|Folding table|Baby clothes|Teapot|Puzzle|Tools|Sign on a pole|Mirror|Mug|Rug|Paperbacks|Free box'],
|
| 16 |
+
['Home renovation', 'adult', 'Paint swatch|Drywall|Tiles|Grout|Contractor|Budget overrun|Dust sheet|Skip|Ladder|Floor sander|Wallpaper|Kitchen island|Spirit level|Building permit|Moodboard|Power drill|Showroom|Delay|Grand reveal|New tap'],
|
| 17 |
+
['Things at a wine tasting', 'adult', 'Glass|Cork|Sommelier|Swirl|Spittoon|Vineyard|Cheese|Crackers|Tasting notes|Barrel|Rosé|Label|Decanter|Grapes|Tour bus|Aroma|Cellar|Tasting fee|Gift shop|Oak'],
|
| 18 |
+
['Midlife hobbies', 'adult', 'Sourdough|Pickleball|Birdwatching|Marathon|Woodwork|Golf|Pottery|Book club|Sea swimming|Home brewing|Gardening|Camper van|Cycling club|Wild camping|Choir|Yoga|Genealogy|Fishing|Running club|Model trains'],
|
| 19 |
+
['Things on a road trip', 'adult', 'Playlist|Gas station snacks|Map app|Toll booth|Rest stop|Motel|Car games|Sunglasses|Wrong turn|Scenic view|Phone charger|Cooler|Traffic jam|Diner|Souvenir|Speed camera|Cassette|Flat tire|Back seat nap|Roadside sign'],
|
| 20 |
+
['Things at a baby shower', 'adult', 'Diaper cake|Onesie|Gift table|Guess the date|Balloon arch|Cupcakes|Baby bottle|Name ideas|Stroller|Bib|Gender reveal|Punch bowl|Advice cards|Knitted hat|Rattle|Mama-to-be|Photo wall|Mocktail|Thank you note|Bassinet'],
|
| 21 |
+
['Office party things', 'adult', 'Name tag|Secret gift|Karaoke|Paper hat|Buffet|Speech from the boss|Group photo|Ugly sweater|Raffle|DJ|Mocktail|Late taxi|Awkward dance|Cake|Team award|Balloon|Playlist|Quiz|Coat check|Small talk'],
|
| 22 |
+
['Things in a spa', 'adult', 'Robe|Slippers|Sauna|Steam room|Massage|Face mask|Cucumber slices|Hot stone|Jacuzzi|Calm music|Herbal tea|Towel|Manicure|Pedicure|Scented oil|Locker|Plunge pool|Candle|Relaxation room|Mud wrap'],
|
| 23 |
+
['Things at a housewarming', 'adult', 'Plant|Doormat|Candle|Bottle opener|House tour|Takeout food|Cardboard boxes|Spare key|Neighbors|Toolkit|Welcome card|Playlist|Snacks|New sofa|Paint smell|Folding chairs|Fairy lights|Coasters|Bread and salt|Photo'],
|
| 24 |
+
['Things at a gym', 'adult', 'Treadmill|Dumbbell|Locker room|Water bottle|Yoga mat|Personal trainer|Rowing machine|Mirror|Protein shake|Spin class|Kettlebell|Bench press|Towel|Playlist|Membership card|Stretching|Squat rack|Stair climber|Sweatband|Boxing bag'],
|
| 25 |
+
] },
|
| 26 |
+
{ id: 'movies', name: 'Movie night', emoji: '🎬', lang: 'en', pass: true, desc: 'Genres, sets, tropes and snacks for film fans', cats: [
|
| 27 |
+
['Movie genres', 'film', 'Comedy|Horror|Western|Musical|Romance|Thriller|Science fiction|Fantasy|Documentary|Animation|Mystery|Heist|Disaster|Sports drama|Spy|Superhero|War|Coming of age|Courtroom|Road movie'],
|
| 28 |
+
['Things on a film set', 'film', 'Camera|Director chair|Clapperboard|Boom mic|Spotlight|Green screen|Script|Makeup trailer|Costume rack|Dolly track|Stunt double|Extras|Catering|Megaphone|Monitor|Call sheet|Smoke machine|Fake rain|Cue card|Take two'],
|
| 29 |
+
['Heist movie team', 'film', 'Mastermind|Hacker|Safe cracker|Getaway driver|Inside man|Disguise expert|Lookout|Pickpocket|Muscle|Forger|Gadget maker|Blueprint|Vault|Laser grid|Countdown|Double cross|Getaway van|Masks|Secret signal|Loot bag'],
|
| 30 |
+
['Space movie things', 'film', 'Spaceship|Alien|Laser|Space station|Hyperdrive|Robot sidekick|Asteroid field|Captain|Warp gate|Escape pod|Space suit|Mission control|Wormhole|Distress signal|Star map|Cryo sleep|Docking bay|Landing party|Mothership|Tractor beam'],
|
| 31 |
+
['Western movie things', 'film', 'Cowboy hat|Saloon|Sheriff badge|Horse|Tumbleweed|Stagecoach|Wanted poster|Lasso|Spurs|Ranch|Canyon|Campfire|Harmonica|Gold rush|Desert town|Swinging doors|Train robbery|Bandana|Showdown|Sunset ride'],
|
| 32 |
+
['Spooky movie things', 'film', 'Creaky stairs|Haunted doll|Full moon|Fog|Flickering lights|Old mansion|Cabin in the woods|Basement|Mysterious phone call|Mirror|Attic|Black cat|Thunderstorm|Spooky radio|Scream|Jump scare|Ghost|Abandoned hospital|Graveyard|Shadow'],
|
| 33 |
+
['Things at a film award show', 'film', 'Red carpet|Trophy|Envelope|Speech|Gown|Tuxedo|Host|Paparazzi|Nominee|Orchestra|Standing ovation|Limousine|Interview|After party|Seat filler|Spotlight|Golden statue|Acceptance list|Teleprompter|Applause'],
|
| 34 |
+
['Movie snacks', 'film', 'Popcorn|Nachos|Hot dog|Gummy bears|Chocolate bar|Licorice|Slushie|Soda|Pretzel|Ice cream tub|Candy corn|Peanut candy|Cookie|Fruit chews|Sour sweets|Cotton candy|Water bottle|Nuts|Jelly beans|Butter topping'],
|
| 35 |
+
['Superhero movie things', 'film', 'Cape|Mask|Secret lair|Villain monologue|Origin story|Team up|Post-credits scene|Sidekick|Gadget|Explosion|Rooftop|City skyline|Shield|Hammer|Suit upgrade|Final battle|Portal|Mentor|Secret identity|Slow motion'],
|
| 36 |
+
['Romantic comedy moments', 'film', 'Meet cute|Airport dash|Rain kiss|Makeover|Best friend advice|Grand gesture|Misunderstanding|Wedding crash|Fake dating|Love letter|Boombox|Running late|Coffee spill|Second chance|Dance scene|Montage|Proposal|Bookshop|Snow day|Happy ending'],
|
| 37 |
+
['Animated movie things', 'film', 'Talking animal|Sidekick|Musical number|Villain lair|Magic spell|Princess|Toy|Robot|Road trip|Fairy godparent|Comic relief|Sea adventure|Monster|Snowman|Lost kingdom|Dragon friend|Wise elder|Big chase|Transformation|Lesson learned'],
|
| 38 |
+
['Things in a cinema lobby', 'film', 'Ticket booth|Poster|Popcorn machine|Arcade game|Carpet|Queue|Snack counter|Trailer screen|Cardboard cutout|Restroom|Usher|Showtimes board|Drink fountain|Lounge seat|Gift card|Rope barrier|Photo spot|Escalator|Lost property|Candy wall'],
|
| 39 |
+
['Spy movie gadgets', 'film', 'Laser watch|Shoe phone|Night vision goggles|Exploding pen|Grappling hook|Tiny camera|Tracking device|Car with ejector seat|Fake mustache|Code book|Jetpack|Invisible ink|Briefcase|Earpiece|Lock pick|Drone|Smoke screen|Rope ladder|Hidden radio|Parachute'],
|
| 40 |
+
['Musical movie things', 'film', 'Big number|Chorus line|Spotlight|Duet|Tap shoes|Orchestra|Overture|Encore|Costume change|Rooftop dance|Ballad|Love song|Finale|Curtain call|Stage door|Audition|Jazz hands|Rehearsal|Song and dance|Standing ovation'],
|
| 41 |
+
] },
|
| 42 |
+
{ id: 'office', name: 'Office hours', emoji: '💼', lang: 'en', pass: true, desc: 'Meetings, desks, emails and Friday feelings for team games', cats: [
|
| 43 |
+
['Things in a meeting', 'work', 'Agenda|Slides|Whiteboard|Minutes|Action items|Laptop|Coffee|Projector|Late arrival|Video call|Mute button|Small talk|Deadline|Questions|Follow-up|Notepad|Clock|Name cards|Water jug|Applause'],
|
| 44 |
+
['Office supplies', 'work', 'Stapler|Paper clip|Highlighter|Sticky notes|Binder|Pen|Hole punch|Tape|Scissors|Envelope|Folder|Rubber band|Marker|Notepad|Calculator|Push pin|Label maker|Desk organizer|Index cards|Whiteout'],
|
| 45 |
+
['Things in an email', 'work', 'Subject line|Greeting|Signature|Attachment|Reply all|CC|Out of office|Emoji|Link|Typo|Follow-up|Read receipt|Spam folder|Draft|Unsubscribe|Calendar invite|Thread|Forward|Urgent flag|Kind regards'],
|
| 46 |
+
['Office snacks', 'work', 'Donuts|Fruit bowl|Granola bar|Coffee|Biscuits|Birthday cake|Pizza|Nuts|Popcorn|Candy jar|Tea bags|Crackers|Yogurt|Chocolate|Bagels|Energy drink|Mints|Muffin|Cheese cubes|Leftover sandwiches'],
|
| 47 |
+
['Job titles', 'work', 'Manager|Intern|Designer|Engineer|Accountant|Receptionist|Sales rep|Marketing lead|Analyst|Team lead|Director|Recruiter|Office manager|Consultant|Editor|Product owner|Support agent|Data scientist|Founder|Assistant'],
|
| 48 |
+
['Things on a desk', 'work', 'Monitor|Keyboard|Mouse|Mug|Plant|Lamp|Photo frame|Headphones|Notepad|Pen pot|Calendar|Phone|Water bottle|Charger|Stress ball|Name plate|Sticky notes|Snack|Desk fan|Coaster'],
|
| 49 |
+
['Work from home things', 'work', 'Slippers|Video call|Kitchen table|Pet on keyboard|Mute button|Background blur|Laptop stand|Coffee machine|Doorbell|Sweatpants|Ring light|Headset|Lunch walk|Chat app|Couch|Washing machine|Noise|Wi-fi|Virtual background|Home office'],
|
| 50 |
+
['Things at a conference', 'work', 'Badge|Lanyard|Keynote|Booth|Free pen|Tote bag|Panel|Coffee break|Name tag|Business card|Stage|Networking|Wi-fi password|Workshop|Schedule|Sponsor|Microphone|Q and A|Swag|Hotel'],
|
| 51 |
+
['Office sounds', 'work', 'Printer|Keyboard clatter|Phone ringing|Coffee machine|Door swipe|Laughter|Chair squeak|Elevator ding|Stapler|Microwave beep|Air con hum|Shredder|Notification ping|Whispering|Footsteps|Vending machine|Paper rustle|Water cooler|Kettle|Applause'],
|
| 52 |
+
['Things at the water cooler', 'work', 'Gossip|Paper cup|Weekend plans|Weather chat|Sports talk|Office news|New hire|Holiday plans|Water bottle|Coworker|Joke|Birthday card|TV show|Small talk|Rumor|Snack|Coffee mug|Pet photos|Lunch plans|Traffic story'],
|
| 53 |
+
['Things a boss says', 'work', 'Quick sync|Circle back|Deadline|Great job|Big picture|Touch base|Action item|Team effort|Bandwidth|Moving forward|Priorities|Let us take this offline|Win|Budget|Stretch goal|Feedback|Open door|Quarter|Synergy|Heads up'],
|
| 54 |
+
['Office party games', 'work', 'Secret gift swap|Quiz|Karaoke|Charades|Bingo|Scavenger hunt|Pictionary|Two truths and a lie|Raffle|Costume contest|Bake off|Trivia|Dance off|Paper plane race|Photo booth|Desk decorating|Guess the baby photo|Treasure hunt|Name that tune|Musical chairs'],
|
| 55 |
+
] },
|
| 56 |
+
{ id: 'holidays', name: 'Holidays', emoji: '🎄', lang: 'en', pass: true, desc: 'Festive, spooky, sunny and sweet: holiday decks for the whole year', cats: [
|
| 57 |
+
['Winter holiday things', 'holiday', 'Tree|Lights|Stocking|Wreath|Gift wrap|Snow globe|Gingerbread|Candy cane|Reindeer|Sleigh|Ornament|Carol|Eggnog|Fireplace|Mittens|Advent calendar|Star|Tinsel|Hot cocoa|Mistletoe'],
|
| 58 |
+
['Halloween night', 'holiday', 'Costume|Pumpkin|Candy bag|Doorbell|Ghost|Witch hat|Spider|Bat|Jack-o-lantern|Haunted house|Mask|Skeleton|Glow stick|Full moon|Cauldron|Face paint|Black cat|Broom|Candy corn|Spooky music'],
|
| 59 |
+
['New Year things', 'holiday', 'Countdown|Fireworks|Party hat|Resolution|Midnight|Confetti|Clock|Calendar|Sparkling cider|Sparkler|Noisemaker|Toast|Kiss|Disco ball|Ball drop|Fresh start|Gym membership|Party|Glitter|Song'],
|
| 60 |
+
['Valentines Day things', 'holiday', 'Heart|Rose|Chocolate box|Card|Cupid|Arrow|Candle dinner|Teddy bear|Love letter|Red|Pink|Kiss|Ring|Poem|Bouquet|Date night|Sweetheart candy|Balloon|Hug|Secret admirer'],
|
| 61 |
+
['Easter things', 'holiday', 'Egg hunt|Bunny|Basket|Chick|Chocolate egg|Daffodil|Bonnet|Lamb|Jelly beans|Painted egg|Spring|Hot cross bun|Grass|Lily|Picnic|Carrots|Pastel|Ribbon|Garden|Brunch'],
|
| 62 |
+
['Thanksgiving things', 'holiday', 'Turkey|Stuffing|Cranberry sauce|Pumpkin pie|Parade|Football|Mashed potatoes|Gravy|Family|Leaf garland|Corn|Pilgrim hat|Cornucopia|Leftovers|Grateful|Table|Sweet potato|Wishbone|Nap|Pecan pie'],
|
| 63 |
+
['Summer holiday things', 'holiday', 'Beach|Sunscreen|Ice cream|Pool|Road trip|Camping|Fireworks|Barbecue|Flip-flops|Hammock|Watermelon|Lemonade|Swimsuit|Sunhat|Postcard|Sandcastle|Fan|Popsicle|Sunglasses|Boat trip'],
|
| 64 |
+
['Lunar New Year things', 'holiday', 'Red envelope|Lantern|Dragon dance|Lion dance|Dumplings|Fireworks|Firecrackers|Zodiac animal|Oranges|Family dinner|Couplets|Gold|Drums|Parade|Spring rolls|Red clothes|Cleaning the house|Fish dish|Tea|Good luck'],
|
| 65 |
+
['Diwali things', 'holiday', 'Diya lamp|Rangoli|Sweets|Fireworks|Candles|New clothes|Lights|Flowers|Family visit|Gifts|Sparklers|Marigold|Prayer|Feast|Lanterns|Decorations|Henna|Cleaning|Music|Laddoo'],
|
| 66 |
+
['Birthday party themes', 'holiday', 'Pirates|Princess|Dinosaurs|Space|Superheroes|Unicorns|Under the sea|Jungle|Circus|Sports|Rainbow|Wild West|Robots|Fairies|Camping|Detectives|Mermaids|Cars|Science lab|Candy land'],
|
| 67 |
+
['Things at a summer festival', 'holiday', 'Stage|Tent|Wristband|Food truck|Glitter|Flags|Mud|Rain poncho|Ferris wheel|Lemonade stand|Face paint|Crowd|Headliner|Map|Camp chair|Bunting|Sunscreen|Portable toilet|Lights|Dancing'],
|
| 68 |
+
['Back to school things', 'holiday', 'Backpack|Pencil case|New shoes|Lunchbox|Timetable|Notebook|Uniform|School bus|Teacher|Classroom|Locker|Ruler|Crayons|Calculator|Name label|First day photo|Homework|Glue stick|Water bottle|Bell'],
|
| 69 |
+
] },
|
| 70 |
+
{ id: 'es', name: 'Español', emoji: '🇪🇸', lang: 'es', pass: true, desc: 'Categorías y palabras en español', cats: [
|
| 71 |
+
['Frutas', 'es-food', 'Manzana|Plátano|Naranja|Fresa|Uva|Sandía|Piña|Mango|Pera|Cereza|Limón|Melón|Kiwi|Durazno|Coco|Ciruela|Frambuesa|Papaya|Higo|Granada'],
|
| 72 |
+
['Animales', 'es-animals', 'Perro|Gato|Caballo|Vaca|Elefante|León|Tigre|Jirafa|Mono|Oso|Conejo|Tortuga|Delfín|Ballena|Pingüino|Águila|Serpiente|Ratón|Cebra|Lobo'],
|
| 73 |
+
['En la cocina', 'es-home', 'Sartén|Cuchara|Tenedor|Cuchillo|Olla|Plato|Vaso|Horno|Nevera|Microondas|Batidora|Tostadora|Delantal|Taza|Fregadero|Escoba|Servilleta|Cazo|Colador|Rodillo'],
|
| 74 |
+
['Profesiones', 'es-people', 'Médico|Maestra|Bombero|Cocinero|Piloto|Policía|Enfermera|Panadero|Carpintero|Dentista|Cartero|Granjero|Pintor|Músico|Veterinaria|Astronauta|Peluquero|Mecánico|Jardinero|Científica'],
|
| 75 |
+
['En la playa', 'es-places', 'Arena|Ola|Toalla|Sombrilla|Concha|Cubo|Pala|Castillo de arena|Gafas de sol|Crema solar|Socorrista|Gaviota|Helado|Bañador|Chanclas|Barco|Cangrejo|Faro|Pelota|Sol'],
|
| 76 |
+
['Deportes', 'es-sports', 'Fútbol|Baloncesto|Tenis|Natación|Ciclismo|Golf|Voleibol|Boxeo|Esquí|Surf|Atletismo|Béisbol|Rugby|Gimnasia|Karate|Patinaje|Remo|Balonmano|Escalada|Bádminton'],
|
| 77 |
+
['Ropa', 'es-things', 'Camisa|Pantalón|Falda|Vestido|Chaqueta|Abrigo|Bufanda|Guantes|Gorro|Calcetines|Zapatos|Botas|Sudadera|Pijama|Corbata|Cinturón|Sombrero|Jersey|Pantalones cortos|Sandalias'],
|
| 78 |
+
['Comida', 'es-food', 'Paella|Tortilla|Pan|Queso|Arroz|Sopa|Ensalada|Pizza|Taco|Empanada|Churros|Gazpacho|Huevo|Pollo|Pescado|Patatas fritas|Jamón|Galleta|Pastel|Helado'],
|
| 79 |
+
['En la ciudad', 'es-places', 'Semáforo|Autobús|Taxi|Parque|Museo|Biblioteca|Hospital|Escuela|Tienda|Mercado|Puente|Plaza|Metro|Edificio|Restaurante|Cine|Farmacia|Estación|Iglesia|Fuente'],
|
| 80 |
+
['Instrumentos musicales', 'es-music', 'Guitarra|Piano|Violín|Tambor|Flauta|Trompeta|Saxofón|Arpa|Acordeón|Castañuelas|Bajo|Ukelele|Maracas|Pandereta|Clarinete|Trombón|Batería|Armónica|Xilófono|Chelo'],
|
| 81 |
+
['Cosas de la escuela', 'es-things', 'Lápiz|Goma|Cuaderno|Mochila|Pizarra|Regla|Tijeras|Pegamento|Libro|Estuche|Calculadora|Sacapuntas|Mapa|Recreo|Profesor|Pupitre|Examen|Diccionario|Rotulador|Compás'],
|
| 82 |
+
['Medios de transporte', 'es-transport', 'Coche|Tren|Avión|Barco|Bicicleta|Moto|Autobús|Metro|Helicóptero|Patinete|Camión|Tranvía|Globo|Cohete|Velero|Furgoneta|Taxi|Teleférico|Ferry|Submarino'],
|
| 83 |
+
['Cosas de la casa', 'es-home', 'Sofá|Cama|Mesa|Silla|Lámpara|Espejo|Alfombra|Cortina|Armario|Estantería|Almohada|Manta|Televisor|Reloj|Ventana|Puerta|Escalera|Ducha|Bañera|Planta'],
|
| 84 |
+
['El tiempo', 'es-science', 'Lluvia|Nieve|Sol|Viento|Nube|Tormenta|Trueno|Relámpago|Niebla|Granizo|Arcoíris|Calor|Frío|Brisa|Huracán|Helada|Llovizna|Tornado|Rocío|Sequía'],
|
| 85 |
+
] },
|
| 86 |
+
{ id: 'pt-br', name: 'Português (BR)', emoji: '🇧🇷', lang: 'pt-BR', pass: true, desc: 'Categorias e palavras em português do Brasil', cats: [
|
| 87 |
+
['Frutas', 'pt-food', 'Maçã|Banana|Laranja|Morango|Uva|Melancia|Abacaxi|Manga|Pera|Cereja|Limão|Melão|Kiwi|Pêssego|Coco|Goiaba|Maracujá|Caju|Açaí|Mamão'],
|
| 88 |
+
['Animais', 'pt-animals', 'Cachorro|Gato|Cavalo|Vaca|Elefante|Leão|Tigre|Girafa|Macaco|Urso|Coelho|Tartaruga|Golfinho|Baleia|Pinguim|Arara|Cobra|Rato|Onça|Tucano'],
|
| 89 |
+
['Na cozinha', 'pt-home', 'Panela|Colher|Garfo|Faca|Prato|Copo|Forno|Geladeira|Micro-ondas|Liquidificador|Torradeira|Avental|Xícara|Pia|Frigideira|Concha|Peneira|Rolo de massa|Fogão|Guardanapo'],
|
| 90 |
+
['Profissões', 'pt-people', 'Médico|Professora|Bombeiro|Cozinheiro|Piloto|Policial|Enfermeira|Padeiro|Carpinteiro|Dentista|Carteiro|Agricultor|Pintor|Músico|Veterinária|Astronauta|Cabeleireiro|Mecânico|Jardineiro|Cientista'],
|
| 91 |
+
['Na praia', 'pt-places', 'Areia|Onda|Toalha|Guarda-sol|Concha|Balde|Pá|Castelo de areia|Óculos de sol|Protetor solar|Salva-vidas|Gaivota|Sorvete|Biquíni|Chinelo|Barco|Caranguejo|Farol|Bola|Água de coco'],
|
| 92 |
+
['Esportes', 'pt-sports', 'Futebol|Basquete|Tênis|Natação|Ciclismo|Golfe|Vôlei|Boxe|Esqui|Surfe|Atletismo|Beisebol|Rúgbi|Ginástica|Capoeira|Patinação|Remo|Handebol|Escalada|Judô'],
|
| 93 |
+
['Roupas', 'pt-things', 'Camisa|Calça|Saia|Vestido|Jaqueta|Casaco|Cachecol|Luvas|Gorro|Meias|Sapatos|Botas|Moletom|Pijama|Gravata|Cinto|Chapéu|Bermuda|Sandália|Boné'],
|
| 94 |
+
['Comidas', 'pt-food', 'Feijoada|Pão de queijo|Brigadeiro|Coxinha|Arroz|Feijão|Pastel|Farofa|Pizza|Salada|Sopa|Frango|Peixe|Batata frita|Tapioca|Bolo|Pudim|Sorvete|Churrasco|Empada'],
|
| 95 |
+
['Na cidade', 'pt-places', 'Semáforo|Ônibus|Táxi|Parque|Museu|Biblioteca|Hospital|Escola|Loja|Mercado|Ponte|Praça|Metrô|Prédio|Restaurante|Cinema|Farmácia|Estação|Igreja|Padaria'],
|
| 96 |
+
['Instrumentos musicais', 'pt-music', 'Violão|Piano|Violino|Tambor|Flauta|Trompete|Saxofone|Harpa|Sanfona|Cavaquinho|Baixo|Pandeiro|Berimbau|Bateria|Clarinete|Trombone|Gaita|Xilofone|Cuíca|Violoncelo'],
|
| 97 |
+
['Coisas da escola', 'pt-things', 'Lápis|Borracha|Caderno|Mochila|Quadro|Régua|Tesoura|Cola|Livro|Estojo|Calculadora|Apontador|Mapa|Recreio|Professor|Carteira|Prova|Dicionário|Caneta|Merenda'],
|
| 98 |
+
['Meios de transporte', 'pt-transport', 'Carro|Trem|Avião|Barco|Bicicleta|Moto|Ônibus|Metrô|Helicóptero|Patinete|Caminhão|Bonde|Balão|Foguete|Veleiro|Van|Táxi|Teleférico|Balsa|Submarino'],
|
| 99 |
+
['Coisas de casa', 'pt-home', 'Sofá|Cama|Mesa|Cadeira|Abajur|Espelho|Tapete|Cortina|Armário|Estante|Travesseiro|Cobertor|Televisão|Relógio|Janela|Porta|Escada|Chuveiro|Rede|Planta'],
|
| 100 |
+
['Festa junina', 'pt-events', 'Fogueira|Quadrilha|Pipoca|Milho|Paçoca|Bandeirinhas|Chapéu de palha|Pé de moleque|Cocada|Pescaria|Correio elegante|Sanfona|Canjica|Pamonha|Balão de papel|Vestido xadrez|Dança|Barraca|Arraiá|Fogos'],
|
| 101 |
+
] },
|
| 102 |
+
{ id: 'de', name: 'Deutsch', emoji: '🇩🇪', lang: 'de', pass: true, desc: 'Kategorien und Wörter auf Deutsch', cats: [
|
| 103 |
+
['Obst', 'de-food', 'Apfel|Banane|Orange|Erdbeere|Traube|Wassermelone|Ananas|Mango|Birne|Kirsche|Zitrone|Melone|Kiwi|Pfirsich|Kokosnuss|Pflaume|Himbeere|Aprikose|Feige|Heidelbeere'],
|
| 104 |
+
['Tiere', 'de-animals', 'Hund|Katze|Pferd|Kuh|Elefant|Löwe|Tiger|Giraffe|Affe|Bär|Hase|Schildkröte|Delfin|Wal|Pinguin|Adler|Schlange|Maus|Zebra|Fuchs'],
|
| 105 |
+
['In der Küche', 'de-home', 'Pfanne|Löffel|Gabel|Messer|Topf|Teller|Glas|Ofen|Kühlschrank|Mikrowelle|Mixer|Toaster|Schürze|Tasse|Spüle|Schneebesen|Sieb|Nudelholz|Herd|Serviette'],
|
| 106 |
+
['Berufe', 'de-people', 'Ärztin|Lehrer|Feuerwehrmann|Koch|Pilotin|Polizist|Krankenpfleger|Bäcker|Tischler|Zahnarzt|Briefträger|Bauer|Maler|Musikerin|Tierärztin|Astronaut|Friseur|Mechaniker|Gärtner|Forscherin'],
|
| 107 |
+
['Am Strand', 'de-places', 'Sand|Welle|Handtuch|Sonnenschirm|Muschel|Eimer|Schaufel|Sandburg|Sonnenbrille|Sonnencreme|Rettungsschwimmer|Möwe|Eis|Badeanzug|Flip-Flops|Boot|Krabbe|Leuchtturm|Ball|Strandkorb'],
|
| 108 |
+
['Sportarten', 'de-sports', 'Fußball|Basketball|Tennis|Schwimmen|Radfahren|Golf|Volleyball|Boxen|Skifahren|Surfen|Leichtathletik|Handball|Rugby|Turnen|Judo|Eislaufen|Rudern|Tischtennis|Klettern|Reiten'],
|
| 109 |
+
['Kleidung', 'de-things', 'Hemd|Hose|Rock|Kleid|Jacke|Mantel|Schal|Handschuhe|Mütze|Socken|Schuhe|Stiefel|Pullover|Schlafanzug|Krawatte|Gürtel|Hut|Kapuzenpulli|Shorts|Sandalen'],
|
| 110 |
+
['Essen', 'de-food', 'Brezel|Bratwurst|Brot|Käse|Kartoffel|Suppe|Salat|Pizza|Schnitzel|Apfelstrudel|Spätzle|Ei|Hähnchen|Fisch|Pommes|Sauerkraut|Kuchen|Keks|Nudeln|Müsli'],
|
| 111 |
+
['In der Stadt', 'de-places', 'Ampel|Bus|Taxi|Park|Museum|Bücherei|Krankenhaus|Schule|Laden|Markt|Brücke|Platz|U-Bahn|Hochhaus|Restaurant|Kino|Apotheke|Bahnhof|Kirche|Brunnen'],
|
| 112 |
+
['Musikinstrumente', 'de-music', 'Gitarre|Klavier|Geige|Trommel|Flöte|Trompete|Saxofon|Harfe|Akkordeon|Bass|Ukulele|Rassel|Tamburin|Klarinette|Posaune|Schlagzeug|Mundharmonika|Xylofon|Cello|Tuba'],
|
| 113 |
+
['Schulsachen', 'de-things', 'Bleistift|Radiergummi|Heft|Rucksack|Tafel|Lineal|Schere|Kleber|Buch|Federmäppchen|Taschenrechner|Spitzer|Landkarte|Pause|Lehrerin|Schultüte|Prüfung|Wörterbuch|Füller|Zirkel'],
|
| 114 |
+
['Verkehrsmittel', 'de-transport', 'Auto|Zug|Flugzeug|Schiff|Fahrrad|Motorrad|Bus|U-Bahn|Hubschrauber|Roller|Lastwagen|Straßenbahn|Heißluftballon|Rakete|Segelboot|Transporter|Taxi|Seilbahn|Fähre|U-Boot'],
|
| 115 |
+
['Im Haus', 'de-home', 'Sofa|Bett|Tisch|Stuhl|Lampe|Spiegel|Teppich|Vorhang|Schrank|Regal|Kissen|Decke|Fernseher|Uhr|Fenster|Tür|Treppe|Dusche|Badewanne|Pflanze'],
|
| 116 |
+
['Weihnachtsmarkt', 'de-events', 'Zuckerwatte|Lebkuchen|Bratapfel|Kerze|Tannenbaum|Lichterkette|Nussknacker|Kinderpunsch|Karussell|Holzspielzeug|Stern|Schneeflocke|Mütze|Mandeln|Stollen|Krippe|Engel|Glocke|Bude|Christbaumkugel'],
|
| 117 |
+
] },
|
| 118 |
+
{ id: 'fr', name: 'Français', emoji: '🇫🇷', lang: 'fr', pass: true, desc: 'Catégories et mots en français', cats: [
|
| 119 |
+
['Fruits', 'fr-food', 'Pomme|Banane|Orange|Fraise|Raisin|Pastèque|Ananas|Mangue|Poire|Cerise|Citron|Melon|Kiwi|Pêche|Noix de coco|Prune|Framboise|Abricot|Figue|Myrtille'],
|
| 120 |
+
['Animaux', 'fr-animals', 'Chien|Chat|Cheval|Vache|Éléphant|Lion|Tigre|Girafe|Singe|Ours|Lapin|Tortue|Dauphin|Baleine|Pingouin|Aigle|Serpent|Souris|Zèbre|Renard'],
|
| 121 |
+
['Dans la cuisine', 'fr-home', 'Poêle|Cuillère|Fourchette|Couteau|Casserole|Assiette|Verre|Four|Frigo|Micro-ondes|Mixeur|Grille-pain|Tablier|Tasse|Évier|Fouet|Passoire|Rouleau à pâtisserie|Cuisinière|Serviette'],
|
| 122 |
+
['Métiers', 'fr-people', 'Médecin|Professeur|Pompier|Cuisinier|Pilote|Policier|Infirmière|Boulanger|Menuisier|Dentiste|Facteur|Agriculteur|Peintre|Musicienne|Vétérinaire|Astronaute|Coiffeur|Mécanicien|Jardinier|Chercheuse'],
|
| 123 |
+
['À la plage', 'fr-places', 'Sable|Vague|Serviette de plage|Parasol|Coquillage|Seau|Pelle|Château de sable|Lunettes de soleil|Crème solaire|Maître-nageur|Mouette|Glace|Maillot de bain|Tongs|Bateau|Crabe|Phare|Ballon|Bouée'],
|
| 124 |
+
['Sports', 'fr-sports', 'Football|Basket|Tennis|Natation|Cyclisme|Golf|Volley|Boxe|Ski|Surf|Athlétisme|Handball|Rugby|Gymnastique|Judo|Patinage|Aviron|Ping-pong|Escalade|Pétanque'],
|
| 125 |
+
['Vêtements', 'fr-things', 'Chemise|Pantalon|Jupe|Robe|Veste|Manteau|Écharpe|Gants|Bonnet|Chaussettes|Chaussures|Bottes|Pull|Pyjama|Cravate|Ceinture|Chapeau|Sweat|Short|Sandales'],
|
| 126 |
+
['Nourriture', 'fr-food', 'Croissant|Baguette|Fromage|Crêpe|Quiche|Soupe|Salade|Pizza|Ratatouille|Omelette|Poulet|Poisson|Frites|Gâteau|Macaron|Madeleine|Tarte|Yaourt|Pâtes|Confiture'],
|
| 127 |
+
['En ville', 'fr-places', 'Feu rouge|Bus|Taxi|Parc|Musée|Bibliothèque|Hôpital|École|Magasin|Marché|Pont|Place|Métro|Immeuble|Restaurant|Cinéma|Pharmacie|Gare|Église|Fontaine'],
|
| 128 |
+
['Instruments de musique', 'fr-music', 'Guitare|Piano|Violon|Tambour|Flûte|Trompette|Saxophone|Harpe|Accordéon|Basse|Ukulélé|Maracas|Tambourin|Clarinette|Trombone|Batterie|Harmonica|Xylophone|Violoncelle|Tuba'],
|
| 129 |
+
['Fournitures scolaires', 'fr-things', 'Crayon|Gomme|Cahier|Cartable|Tableau|Règle|Ciseaux|Colle|Livre|Trousse|Calculatrice|Taille-crayon|Carte|Récréation|Maîtresse|Pupitre|Contrôle|Dictionnaire|Stylo|Compas'],
|
| 130 |
+
['Transports', 'fr-transport', 'Voiture|Train|Avion|Bateau|Vélo|Moto|Bus|Métro|Hélicoptère|Trottinette|Camion|Tramway|Montgolfière|Fusée|Voilier|Camionnette|Taxi|Téléphérique|Ferry|Sous-marin'],
|
| 131 |
+
['À la maison', 'fr-home', 'Canapé|Lit|Table|Chaise|Lampe|Miroir|Tapis|Rideau|Armoire|Étagère|Oreiller|Couverture|Télévision|Horloge|Fenêtre|Porte|Escalier|Douche|Baignoire|Plante'],
|
| 132 |
+
['À la boulangerie', 'fr-food', 'Baguette|Croissant|Pain au chocolat|Brioche|Éclair|Tarte aux pommes|Chausson|Pain de campagne|Macaron|Millefeuille|Boulanger|Four|Farine|Caisse|Sachet|Vitrine|Religieuse|Flan|Chouquette|Fougasse'],
|
| 133 |
+
] },
|
| 134 |
+
];
|
| 135 |
+
|
| 136 |
+
export const packById = (id) => PACKS.find((p) => p.id === id);
|
| 137 |
+
|
| 138 |
+
/** Every category of the chosen packs as {cat, group, words[], pack, lang}. Fun Pass packs are skipped unless pro. */
|
| 139 |
+
export function deck(ids = ['classic'], pro = false) {
|
| 140 |
+
const out = [];
|
| 141 |
+
for (const id of ids) {
|
| 142 |
+
const p = packById(id);
|
| 143 |
+
if (!p || (p.pass && !pro)) continue;
|
| 144 |
+
for (const [cat, group, words] of p.cats) out.push({ cat, group, words: words.split('|'), pack: p.id, lang: p.lang });
|
| 145 |
+
}
|
| 146 |
+
return out;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export function counts() {
|
| 150 |
+
return PACKS.map((p) => ({ id: p.id, cats: p.cats.length, words: p.cats.reduce((n, c) => n + c[2].split('|').length, 0) }));
|
| 151 |
+
}
|
hushfox/safety.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hushfox safety filter: keeps every deck family-safe. Used by tests/words.test.mjs on the hand-written library and
|
| 2 |
+
// by the room Worker on every AI-generated category before it is cached or shown. Whole-word match on a normalised
|
| 3 |
+
// copy (accents stripped, lower case), so "glass" or "Scunthorpe"-style words are not caught by accident.
|
| 4 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 5 |
+
const BLOCK = [
|
| 6 |
+
// profanity and insults
|
| 7 |
+
'damn', 'hell', 'crap', 'shit', 'fuck', 'fucking', 'bitch', 'bastard', 'ass', 'arse', 'asshole', 'dick', 'cock', 'piss',
|
| 8 |
+
'bollocks', 'bugger', 'wanker', 'twat', 'prick', 'slut', 'whore', 'idiot', 'moron', 'stupid', 'retard', 'loser', 'dumb',
|
| 9 |
+
// sexual
|
| 10 |
+
'sex', 'sexy', 'porn', 'nude', 'naked', 'boobs', 'boob', 'breast', 'penis', 'vagina', 'orgasm', 'condom', 'erotic',
|
| 11 |
+
'stripper', 'strip club', 'brothel', 'fetish', 'kinky', 'lingerie', 'hooker', 'escort', 'viagra', 'onlyfans',
|
| 12 |
+
// drugs, alcohol excess, gambling
|
| 13 |
+
'cocaine', 'heroin', 'meth', 'weed', 'marijuana', 'cannabis', 'joint', 'bong', 'crack', 'ecstasy', 'lsd', 'drunk',
|
| 14 |
+
'hangover', 'vodka shot', 'shots', 'casino', 'gambling', 'cigarette', 'vape', 'tobacco',
|
| 15 |
+
// violence, weapons and death
|
| 16 |
+
'gun', 'rifle', 'pistol', 'shotgun', 'bullet', 'bomb', 'grenade', 'kill', 'killer', 'murder', 'suicide', 'blood',
|
| 17 |
+
'gore', 'corpse', 'torture', 'terrorist', 'massacre', 'stab', 'shoot', 'shooting', 'war crime', 'execution',
|
| 18 |
+
// hate and extremism
|
| 19 |
+
'nazi', 'hitler', 'kkk', 'racist', 'slave', 'slavery', 'genocide', 'jihad',
|
| 20 |
+
];
|
| 21 |
+
const N = (s) => String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
| 22 |
+
const SET = new Set(BLOCK.map(N));
|
| 23 |
+
// Harmless everyday phrases that contain a blocked word.
|
| 24 |
+
const ALLOW = new Set(['staple gun', 'glue gun', 'water gun', 'pop gun', 'water pistol', 'shooting star', 'bullet train'].map(N));
|
| 25 |
+
|
| 26 |
+
/** The blocked term found in text, or null. Checks single words and two-word phrases. */
|
| 27 |
+
export function blocked(text) {
|
| 28 |
+
if (ALLOW.has(N(text))) return null;
|
| 29 |
+
const w = N(text).split(' ').filter(Boolean);
|
| 30 |
+
for (let i = 0; i < w.length; i++) {
|
| 31 |
+
if (SET.has(w[i])) return w[i];
|
| 32 |
+
if (i + 1 < w.length && SET.has(`${w[i]} ${w[i + 1]}`)) return `${w[i]} ${w[i + 1]}`;
|
| 33 |
+
}
|
| 34 |
+
return null;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** A word (or, with max 40, a category) is acceptable: letters (any language), spaces and simple punctuation, 1-max chars, not blocked. */
|
| 38 |
+
export function okEntry(s, max = 32) {
|
| 39 |
+
const t = String(s || '').trim();
|
| 40 |
+
return t.length >= 1 && t.length <= max && /^[\p{L}\p{N}][\p{L}\p{N} '’\-&.]*$/u.test(t) && !blocked(t);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Clean an AI answer {category, words[]} -> {cat, words[]} or null. Needs 8+ safe, distinct words. */
|
| 44 |
+
export function cleanAi(obj) {
|
| 45 |
+
if (!obj || typeof obj !== 'object') return null;
|
| 46 |
+
const cat = String(obj.category || '').trim().replace(/\s+/g, ' ');
|
| 47 |
+
if (!okEntry(cat, 40)) return null;
|
| 48 |
+
const seen = new Set();
|
| 49 |
+
const words = [];
|
| 50 |
+
for (const raw of Array.isArray(obj.words) ? obj.words : []) {
|
| 51 |
+
const w = String(raw || '').trim().replace(/\s+/g, ' ').replace(/^[-*\d.\s]+/, '');
|
| 52 |
+
const k = N(w);
|
| 53 |
+
if (!okEntry(w) || seen.has(k) || k === N(cat)) continue;
|
| 54 |
+
seen.add(k);
|
| 55 |
+
words.push(w.charAt(0).toUpperCase() + w.slice(1));
|
| 56 |
+
if (words.length >= 16) break;
|
| 57 |
+
}
|
| 58 |
+
return words.length >= 8 ? { cat: cat.charAt(0).toUpperCase() + cat.slice(1), words } : null;
|
| 59 |
+
}
|
hushfox/shot-phone.png
ADDED
|
Git LFS Details
|
hushfox/style.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Hushfox layout: big, readable party screens for a phone passed around a room. Colours: plum night + fox orange.
|
| 2 |
+
(c) 2026 CyberMax. All rights reserved. */
|
| 3 |
+
:root{--acc:#FF7A2F;--acc-ink:#1C1530;--fox:#FF7A2F;--night:#1C1530;--night2:#2B2048;--cream:#FFF3E6}
|
| 4 |
+
.headline h1 .num{color:var(--muted);font-weight:700;font-size:.62em}
|
| 5 |
+
.tag{margin:0 0 10px}
|
| 6 |
+
.stage{position:relative;margin-top:8px;border-radius:24px;overflow:hidden;box-shadow:var(--shadow);background:radial-gradient(120% 90% at 50% 0%,#3A2A63 0%,var(--night) 62%);color:var(--cream);min-height:clamp(520px,calc(100svh - 150px),860px);display:flex;flex-direction:column}
|
| 7 |
+
.scr{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;padding:20px 16px 22px;text-align:center;animation:pop .28s cubic-bezier(.2,1.3,.4,1)}
|
| 8 |
+
.scr.top{justify-content:flex-start}
|
| 9 |
+
@keyframes pop{0%{opacity:0;transform:translateY(10px) scale(.98)}100%{opacity:1;transform:none}}
|
| 10 |
+
.logo{font:900 clamp(46px,13vw,76px)/1 system-ui,sans-serif;letter-spacing:-.03em;margin:0;color:#fff;text-shadow:0 4px 0 #C2541C,0 12px 30px rgba(0,0,0,.45)}
|
| 11 |
+
.logo i{color:var(--fox);font-style:normal}
|
| 12 |
+
.fox{width:min(150px,36vw);height:auto;filter:drop-shadow(0 10px 20px rgba(0,0,0,.4))}
|
| 13 |
+
.fox.sm{width:72px}
|
| 14 |
+
.lead{margin:0;font-size:clamp(17px,4.6vw,21px);font-weight:700;color:#F3E9FF;max-width:30ch}
|
| 15 |
+
.small{font-size:14px}.muted2{color:#C9BEDF}
|
| 16 |
+
.big{display:flex;flex-direction:column;align-items:flex-start;gap:2px;width:min(420px,100%);text-align:left;background:rgba(255,255,255,.08);border:2px solid rgba(255,255,255,.18);color:#fff;border-radius:18px;padding:14px 16px;font:inherit;cursor:pointer;touch-action:manipulation}
|
| 17 |
+
.big b{font-size:21px}.big span{font-size:14px;color:#D8CCEE}.big:hover{background:rgba(255,255,255,.14)}
|
| 18 |
+
.big.hot{background:linear-gradient(135deg,#FF9A4D,#FF6A2A);border-color:transparent;color:var(--night)}.big.hot span{color:#3B2410}
|
| 19 |
+
.go{min-height:64px;font-size:22px;padding:12px 38px;border-radius:18px;box-shadow:0 6px 0 #B9461A;width:min(420px,100%)}
|
| 20 |
+
.go:active{transform:translateY(3px);box-shadow:0 3px 0 #B9461A}
|
| 21 |
+
.lnk{background:none;border:0;color:#D8CCEE;font:inherit;text-decoration:underline;cursor:pointer;padding:8px;min-height:40px}
|
| 22 |
+
.stage .btn.ghost{color:#fff;border-color:rgba(255,255,255,.3)}
|
| 23 |
+
.stage .chip{background:rgba(255,255,255,.08);color:#fff;border-color:rgba(255,255,255,.25)}
|
| 24 |
+
.stage .chip[aria-pressed=true]{background:var(--fox);color:var(--night);border-color:var(--fox)}
|
| 25 |
+
.stage .chip.lock{opacity:.7;border-style:dashed}
|
| 26 |
+
.field{width:min(420px,100%);text-align:left}
|
| 27 |
+
.field label{display:block;font-weight:800;margin:6px 0 4px;font-size:15px}
|
| 28 |
+
.names{list-style:none;margin:0;padding:0;display:grid;gap:6px;width:min(420px,100%)}
|
| 29 |
+
.names li{display:flex;gap:6px;align-items:center}
|
| 30 |
+
.inp{flex:1;min-height:48px;border-radius:12px;border:2px solid rgba(255,255,255,.22);background:rgba(0,0,0,.25);color:#fff;font:inherit;font-size:18px;padding:8px 12px;width:100%}
|
| 31 |
+
.inp:focus{outline:3px solid var(--fox);outline-offset:1px}
|
| 32 |
+
.inp.code{text-transform:uppercase;letter-spacing:.3em;font-weight:900;font-size:26px;text-align:center}
|
| 33 |
+
.x{min-width:48px;min-height:48px;border-radius:12px;border:0;background:rgba(255,255,255,.1);color:#fff;font-size:20px;cursor:pointer}
|
| 34 |
+
.card{width:min(420px,100%);border-radius:26px;padding:26px 18px;background:var(--cream);color:var(--night);box-shadow:0 14px 40px rgba(0,0,0,.45);transform-style:preserve-3d}
|
| 35 |
+
.card.foxcard{background:linear-gradient(160deg,#FF9A4D,#E8571C);color:#1C1530}
|
| 36 |
+
.card .k{margin:0;font-size:14px;font-weight:800;text-transform:uppercase;letter-spacing:.12em;opacity:.7}
|
| 37 |
+
.card .w{margin:6px 0 0;font:900 clamp(34px,10vw,58px)/1.05 system-ui,sans-serif;letter-spacing:-.02em;word-break:break-word}
|
| 38 |
+
.card .c{margin:10px 0 0;font-size:18px;font-weight:700}
|
| 39 |
+
.card.hidden .w,.card.hidden .c{filter:blur(14px);user-select:none}
|
| 40 |
+
.flip{animation:flip .5s ease}
|
| 41 |
+
@keyframes flip{0%{transform:rotateY(90deg)}100%{transform:none}}
|
| 42 |
+
.timer{position:relative;width:min(200px,50vw);aspect-ratio:1}
|
| 43 |
+
.timer svg{width:100%;height:100%;transform:rotate(-90deg)}
|
| 44 |
+
.timer circle{fill:none;stroke-width:14}.timer .bg{stroke:rgba(255,255,255,.12)}.timer .fg{stroke:var(--fox);stroke-linecap:round;transition:stroke-dashoffset .2s linear}
|
| 45 |
+
.timer b{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font:900 clamp(48px,14vw,72px)/1 system-ui,sans-serif;font-variant-numeric:tabular-nums}
|
| 46 |
+
.timer.hurry .fg{stroke:#FF4D4D}.timer.hurry b{animation:beat .5s ease-in-out infinite}
|
| 47 |
+
@keyframes beat{50%{transform:scale(1.08)}}
|
| 48 |
+
.cat{margin:0;font:900 clamp(26px,7.5vw,40px)/1.1 system-ui,sans-serif}
|
| 49 |
+
.twist{width:min(420px,100%);border-radius:18px;padding:12px 14px;background:#FFE36B;color:#1C1530;font-weight:800;font-size:clamp(17px,4.8vw,21px);display:flex;gap:10px;align-items:center;text-align:left;box-shadow:0 8px 24px rgba(0,0,0,.35)}
|
| 50 |
+
.twist i{font-style:normal;font-size:34px}
|
| 51 |
+
.twist.wait{background:rgba(255,255,255,.08);color:#D8CCEE;font-weight:700;box-shadow:none;border:2px dashed rgba(255,255,255,.2)}
|
| 52 |
+
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:8px;width:min(460px,100%)}
|
| 53 |
+
.pick{min-height:60px;border-radius:16px;border:2px solid rgba(255,255,255,.22);background:rgba(255,255,255,.08);color:#fff;font:inherit;font-weight:800;font-size:clamp(17px,4.6vw,20px);cursor:pointer;padding:8px;touch-action:manipulation;word-break:break-word}
|
| 54 |
+
.pick:hover{background:rgba(255,255,255,.16)}.pick.on{background:var(--fox);color:var(--night);border-color:var(--fox)}
|
| 55 |
+
.pick.good{background:#2FD3A0;color:#0B2A20;border-color:#2FD3A0}.pick.bad{background:#FF5C5C;color:#2A0B0B;border-color:#FF5C5C}
|
| 56 |
+
.pick small{display:block;font-size:12px;font-weight:600;opacity:.8}
|
| 57 |
+
.board{list-style:none;margin:0;padding:0;width:min(420px,100%);display:grid;gap:6px}
|
| 58 |
+
.board li{display:flex;align-items:center;gap:10px;background:rgba(255,255,255,.08);border-radius:14px;padding:10px 14px;font-weight:700;font-size:18px}
|
| 59 |
+
.board li b{margin-left:auto;font-size:22px;font-variant-numeric:tabular-nums}.board li.win{background:linear-gradient(90deg,rgba(255,122,47,.45),rgba(255,122,47,.12))}
|
| 60 |
+
.board li .d{color:#2FD3A0;font-size:15px}.board li .off{opacity:.5}
|
| 61 |
+
.out{margin:0;font:900 clamp(30px,9vw,48px)/1.05 system-ui,sans-serif}
|
| 62 |
+
.emoji{font-size:34px;letter-spacing:4px;margin:0}
|
| 63 |
+
.code-big{font:900 clamp(54px,17vw,92px)/1 system-ui,sans-serif;letter-spacing:.14em;margin:0;color:#FFE36B}
|
| 64 |
+
.banner{width:min(420px,100%);border-radius:14px;padding:8px 12px;background:rgba(255,227,107,.15);border:1.5px solid #FFE36B;color:#FFF3C4;font-weight:700;font-size:15px}
|
| 65 |
+
.net{position:absolute;top:8px;left:50%;transform:translateX(-50%);background:#FF5C5C;color:#2A0B0B;font-weight:800;border-radius:999px;padding:4px 12px;font-size:13px;z-index:5}
|
| 66 |
+
.hunt-bar{width:min(460px,100%);height:12px;border-radius:999px;background:rgba(255,255,255,.12);overflow:hidden}
|
| 67 |
+
.hunt-bar i{display:block;height:100%;background:var(--fox);transition:width .2s linear}
|
| 68 |
+
.dots{display:flex;gap:6px;justify-content:center}.dots i{width:12px;height:12px;border-radius:50%;background:rgba(255,255,255,.2)}.dots i.ok{background:#2FD3A0}.dots i.no{background:#FF5C5C}.dots i.now{background:#FFE36B}
|
| 69 |
+
.stage .lv-head,.stage .lv-world h3,.stage .lv-note,.stage .lv-twists{color:#E9DEFB}
|
| 70 |
+
.stage .lvmap{width:min(460px,100%);text-align:left}
|
| 71 |
+
.stage .fp-after{background:rgba(255,255,255,.08);border-radius:16px;padding:10px 14px;text-align:left;width:min(460px,100%)}
|
| 72 |
+
.under{justify-content:space-between;margin-top:10px}
|
| 73 |
+
.packs{display:grid;gap:6px;width:min(420px,100%)}
|
| 74 |
+
.pack{display:flex;gap:10px;align-items:center;text-align:left;border-radius:14px;border:2px solid rgba(255,255,255,.2);background:rgba(255,255,255,.06);color:#fff;font:inherit;padding:8px 12px;cursor:pointer;min-height:52px}
|
| 75 |
+
.pack[aria-pressed=true]{border-color:var(--fox);background:rgba(255,122,47,.22)}.pack i{font-style:normal;font-size:24px}.pack b{display:block}.pack span{font-size:13px;color:#D8CCEE}.pack em{margin-left:auto;font-style:normal;font-size:12px;font-weight:800;color:#FFE36B;white-space:nowrap}
|
| 76 |
+
@media (prefers-reduced-motion:reduce){.scr,.flip,.timer.hurry b{animation:none}}
|
hushfox/sw.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
|
| 2 |
+
// network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
|
| 3 |
+
const GAME = 'hushfox';
|
| 4 |
+
const CACHE = `cmx-${GAME}-250a33fa82`;
|
| 5 |
+
const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "levels.js", "manifest.webmanifest", "net.js", "offer.js", "packs.js", "safety.js", "style.css", "words.js"];
|
| 6 |
+
|
| 7 |
+
self.addEventListener('install', (e) => {
|
| 8 |
+
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
|
| 9 |
+
});
|
| 10 |
+
self.addEventListener('activate', (e) => {
|
| 11 |
+
e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(`cmx-${GAME}-`) && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
|
| 12 |
+
});
|
| 13 |
+
self.addEventListener('fetch', (e) => {
|
| 14 |
+
const u = new URL(e.request.url);
|
| 15 |
+
if (e.request.method !== 'GET' || u.origin !== location.origin || !u.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
|
| 16 |
+
if (e.request.mode === 'navigate') {
|
| 17 |
+
e.respondWith(fetch(e.request).then((r) => { const c = r.clone(); caches.open(CACHE).then((x) => x.put(u.pathname, c)); return r; })
|
| 18 |
+
.catch(() => caches.match(u.pathname).then((r) => r || caches.match('index.html'))));
|
| 19 |
+
return;
|
| 20 |
+
}
|
| 21 |
+
e.respondWith(caches.match(e.request, { ignoreSearch: true }).then((r) => r || fetch(e.request).then((res) => {
|
| 22 |
+
if (res.ok) { const c = res.clone(); caches.open(CACHE).then((x) => x.put(e.request, c)); }
|
| 23 |
+
return res;
|
| 24 |
+
})));
|
| 25 |
+
});
|
hushfox/words.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Hushfox core deck: the free, hand-written, family-safe category library (no AI, no scraping).
|
| 2 |
+
// Format: [category, group, 'word|word|…']. The group lets the solo Fox Hunt pick a tricky "fox" word from a
|
| 3 |
+
// neighbouring category. tests/words.test.mjs checks every entry for duplicates, length and the blocklist.
|
| 4 |
+
// (c) 2026 CyberMax. All rights reserved.
|
| 5 |
+
export const CORE = [
|
| 6 |
+
['Pizza toppings', 'food', 'Pepperoni|Mushroom|Olive|Pineapple|Onion|Ham|Sausage|Spinach|Basil|Tomato|Mozzarella|Bacon|Anchovy|Jalapeno|Bell pepper|Garlic|Chicken|Artichoke|Corn|Feta|Sweetcorn|Meatball|Arugula|Parmesan'],
|
| 7 |
+
['Breakfast foods', 'food', 'Pancake|Waffle|Cereal|Toast|Bagel|Omelette|Oatmeal|Croissant|Bacon|Hash brown|Yogurt|Granola|Muffin|French toast|Scrambled eggs|Porridge|Smoothie|Sausage|Crepe|Fruit salad|Biscuit|Breakfast burrito|Donut|Cinnamon roll'],
|
| 8 |
+
['Fruits', 'food', 'Apple|Banana|Mango|Strawberry|Grape|Watermelon|Pineapple|Kiwi|Peach|Pear|Cherry|Orange|Lemon|Lime|Blueberry|Raspberry|Coconut|Papaya|Plum|Apricot|Fig|Pomegranate|Cantaloupe|Lychee'],
|
| 9 |
+
['Vegetables', 'food', 'Carrot|Broccoli|Potato|Cucumber|Lettuce|Pea|Cabbage|Celery|Zucchini|Pumpkin|Spinach|Cauliflower|Radish|Beetroot|Eggplant|Kale|Asparagus|Leek|Turnip|Green bean|Sweet potato|Artichoke|Brussels sprout|Onion'],
|
| 10 |
+
['Desserts', 'food', 'Cake|Ice cream|Brownie|Cookie|Pie|Pudding|Cheesecake|Cupcake|Tiramisu|Jelly|Mousse|Macaron|Churro|Sundae|Cobbler|Eclair|Fudge|Trifle|Sorbet|Banana split|Lemon tart|Doughnut|Waffle cone|Apple crumble'],
|
| 11 |
+
['Sandwich fillings', 'food', 'Cheese|Ham|Turkey|Tuna|Egg salad|Peanut butter|Jam|Tomato|Lettuce|Cucumber|Chicken|Avocado|Hummus|Bacon|Pickle|Salami|Roast beef|Mustard|Mayonnaise|Banana|Honey|Falafel|Coleslaw|Meatball'],
|
| 12 |
+
['Snacks', 'food', 'Popcorn|Pretzel|Chips|Crackers|Nuts|Trail mix|Cheese stick|Granola bar|Rice cake|Apple slices|Carrot sticks|Pickle|Beef jerky|Gummy bears|Raisins|Cookie|Seaweed|Fruit leather|Nachos|Edamame|Popsicle|Cheese puff|Yogurt cup|Peanut butter cup'],
|
| 13 |
+
['Things in a fridge', 'food', 'Milk|Butter|Eggs|Cheese|Juice|Yogurt|Ketchup|Mustard|Leftovers|Lettuce|Jam|Pickles|Water bottle|Ham|Lemon|Salsa|Cream|Hot sauce|Grapes|Mayonnaise|Tofu|Olives|Soda|Pudding cup'],
|
| 14 |
+
['Drinks', 'food', 'Water|Milk|Orange juice|Lemonade|Hot chocolate|Tea|Coffee|Smoothie|Milkshake|Iced tea|Apple juice|Soda|Coconut water|Sparkling water|Root beer|Ginger ale|Cola|Grape juice|Chai|Slushie|Herbal tea|Kombucha|Fruit punch|Eggnog'],
|
| 15 |
+
['Ice cream flavors', 'food', 'Vanilla|Chocolate|Strawberry|Mint chip|Cookie dough|Rocky road|Pistachio|Coffee|Butter pecan|Cookies and cream|Salted caramel|Mango|Coconut|Neapolitan|Rum raisin|Bubblegum|Cherry|Lemon|Raspberry ripple|Matcha|Birthday cake|Peanut butter|Banana|Black sesame'],
|
| 16 |
+
['Things you put on toast', 'food', 'Butter|Jam|Honey|Peanut butter|Avocado|Cheese|Marmalade|Cinnamon sugar|Egg|Beans|Tomato|Hummus|Nut spread|Banana|Cream cheese|Mushroom|Sardines|Ricotta|Apple butter|Syrup|Lemon curd|Spinach|Pesto|Ham'],
|
| 17 |
+
['Kitchen tools', 'home', 'Spatula|Whisk|Ladle|Frying pan|Colander|Grater|Rolling pin|Peeler|Tongs|Measuring cup|Blender|Toaster|Kettle|Cutting board|Can opener|Oven mitt|Sieve|Mixing bowl|Saucepan|Timer|Corkscrew|Garlic press|Baking tray|Salad spinner'],
|
| 18 |
+
['Things in a bathroom', 'home', 'Toothbrush|Toothpaste|Towel|Soap|Shampoo|Mirror|Bathtub|Shower curtain|Sink|Bath mat|Comb|Hairdryer|Sponge|Rubber duck|Scale|Floss|Loofah|Conditioner|Razor|Cotton swab|Lotion|Hand towel|Plunger|Laundry basket'],
|
| 19 |
+
['Things in a bedroom', 'home', 'Bed|Pillow|Blanket|Lamp|Alarm clock|Wardrobe|Dresser|Slippers|Curtains|Nightstand|Rug|Mirror|Poster|Teddy bear|Bookshelf|Desk|Hanger|Pajamas|Duvet|Laundry basket|Fan|Mattress|Jewelry box|Night light'],
|
| 20 |
+
['Things in a living room', 'home', 'Sofa|Television|Remote|Cushion|Coffee table|Rug|Bookshelf|Lamp|Armchair|Fireplace|Plant|Curtains|Photo frame|Clock|Speaker|Board game|Throw blanket|Footstool|Vase|Magazine|Window|Side table|Ceiling fan|Puzzle'],
|
| 21 |
+
['Garden things', 'home', 'Shovel|Rake|Hose|Watering can|Wheelbarrow|Flower pot|Seeds|Gnome|Bench|Bird feeder|Lawn mower|Sprinkler|Fence|Gloves|Compost|Pond|Swing|Hammock|Bird bath|Trowel|Greenhouse|Scarecrow|Vegetable patch|Hedge'],
|
| 22 |
+
['Tools in a toolbox', 'home', 'Hammer|Screwdriver|Wrench|Pliers|Tape measure|Saw|Drill|Level|Nails|Screws|Sandpaper|Chisel|Flashlight|Utility knife|Clamp|Allen key|Duct tape|Pencil|Staple gun|Glue gun|File|Socket set|Hacksaw|Mallet'],
|
| 23 |
+
['Cleaning things', 'home', 'Broom|Mop|Vacuum|Duster|Sponge|Bucket|Dustpan|Spray bottle|Rubber gloves|Scrub brush|Cloth|Soap|Trash bag|Squeegee|Feather duster|Paper towel|Bleach|Lint roller|Toilet brush|Window cleaner|Steel wool|Dish rack|Laundry soap|Stain remover'],
|
| 24 |
+
['Furniture', 'home', 'Chair|Table|Sofa|Bed|Wardrobe|Desk|Stool|Bench|Bookcase|Dresser|Armchair|Bunk bed|Rocking chair|Cabinet|Ottoman|Hammock|Crib|Bean bag|Shelf|Futon|Recliner|Coat rack|Chest|Vanity'],
|
| 25 |
+
['Pets', 'animals', 'Dog|Cat|Hamster|Goldfish|Rabbit|Parrot|Guinea pig|Turtle|Budgie|Mouse|Lizard|Ferret|Hermit crab|Canary|Gerbil|Chinchilla|Snake|Tortoise|Pony|Axolotl|Rat|Hedgehog|Frog|Stick insect'],
|
| 26 |
+
['Farm animals', 'animals', 'Cow|Pig|Sheep|Goat|Chicken|Horse|Duck|Donkey|Turkey|Goose|Llama|Rooster|Alpaca|Rabbit|Ox|Pony|Sheepdog|Barn cat|Mule|Bull|Calf|Lamb|Piglet|Hen'],
|
| 27 |
+
['Jungle animals', 'animals', 'Tiger|Monkey|Gorilla|Parrot|Jaguar|Sloth|Toucan|Orangutan|Python|Chimpanzee|Leopard|Tapir|Anaconda|Tree frog|Chameleon|Macaw|Panther|Lemur|Okapi|Crocodile|Boa|Gibbon|Capybara|Anteater'],
|
| 28 |
+
['Ocean animals', 'animals', 'Shark|Whale|Dolphin|Octopus|Jellyfish|Starfish|Crab|Seahorse|Turtle|Squid|Lobster|Seal|Clownfish|Stingray|Eel|Walrus|Orca|Manatee|Pufferfish|Swordfish|Sea urchin|Narwhal|Coral|Shrimp'],
|
| 29 |
+
['Birds', 'animals', 'Eagle|Owl|Parrot|Penguin|Flamingo|Robin|Sparrow|Crow|Peacock|Swan|Pigeon|Hummingbird|Woodpecker|Ostrich|Toucan|Pelican|Seagull|Kiwi|Hawk|Falcon|Duck|Puffin|Stork|Canary'],
|
| 30 |
+
['Bugs and creepy crawlies', 'animals', 'Ant|Bee|Butterfly|Ladybug|Spider|Beetle|Caterpillar|Grasshopper|Dragonfly|Moth|Snail|Worm|Firefly|Cricket|Wasp|Centipede|Mosquito|Fly|Termite|Slug|Stick insect|Praying mantis|Flea|Earwig'],
|
| 31 |
+
['Animals with stripes or spots', 'animals', 'Zebra|Tiger|Leopard|Cheetah|Giraffe|Ladybug|Skunk|Dalmatian|Jaguar|Okapi|Hyena|Bumblebee|Raccoon|Badger|Clownfish|Chipmunk|Lemur|Snow leopard|Whale shark|Salamander|Fawn|Lynx|Ocelot|Spotted owl'],
|
| 32 |
+
['Baby animals', 'animals', 'Puppy|Kitten|Calf|Foal|Lamb|Chick|Duckling|Cub|Joey|Piglet|Fawn|Tadpole|Owlet|Kid|Bunny|Cygnet|Gosling|Eaglet|Pup|Caterpillar|Hatchling|Kit|Cria|Larva'],
|
| 33 |
+
['Dinosaurs and prehistoric life', 'animals', 'Tyrannosaurus|Triceratops|Stegosaurus|Velociraptor|Brachiosaurus|Pterodactyl|Ankylosaurus|Diplodocus|Spinosaurus|Mammoth|Sabre-tooth cat|Iguanodon|Allosaurus|Parasaurolophus|Megalodon|Plesiosaur|Dodo|Trilobite|Archaeopteryx|Pachycephalosaurus|Woolly rhino|Compsognathus|Mosasaur|Giganotosaurus'],
|
| 34 |
+
['Zoo animals', 'animals', 'Lion|Elephant|Giraffe|Zebra|Hippo|Rhino|Kangaroo|Penguin|Gorilla|Bear|Flamingo|Koala|Panda|Camel|Meerkat|Otter|Seal|Tortoise|Lemur|Red panda|Polar bear|Bison|Peacock|Wolf'],
|
| 35 |
+
['Things at the beach', 'places', 'Sand|Shell|Towel|Umbrella|Sunscreen|Bucket|Spade|Sandcastle|Wave|Lifeguard|Surfboard|Flip-flops|Seagull|Crab|Ice cream|Kite|Beach ball|Pier|Rock pool|Sunglasses|Swimsuit|Cooler|Lighthouse|Driftwood'],
|
| 36 |
+
['Things at a playground', 'places', 'Slide|Swing|Seesaw|Sandbox|Monkey bars|Climbing wall|Roundabout|Bench|Tunnel|Rope net|Spring rider|Zip line|Fireman pole|Hopscotch|Water fountain|Tire swing|Balance beam|Trash can|Tree house|Bridge|Sign|Rubber mat|Picnic table|Gate'],
|
| 37 |
+
['Things at the airport', 'places', 'Passport|Suitcase|Boarding pass|Runway|Pilot|Security line|Gate|Departure board|Trolley|Duty free|Control tower|Conveyor belt|Flight attendant|Lounge|Airplane|Customs|Baggage claim|Neck pillow|Escalator|Jet bridge|Taxi rank|Check-in desk|Window seat|Waiting area'],
|
| 38 |
+
['Things at a supermarket', 'places', 'Shopping cart|Basket|Checkout|Cashier|Receipt|Aisle|Barcode|Shelf|Freezer|Bakery|Deli counter|Scale|Coupon|Shopping list|Price tag|Bag|Fruit stand|Cereal box|Self checkout|Trolley|Sample stand|Bulk bin|Flowers|Car park'],
|
| 39 |
+
['Things in a school', 'places', 'Desk|Whiteboard|Pencil|Backpack|Locker|Teacher|Textbook|Calculator|Ruler|Lunchbox|Bell|Library|Gym|Globe|Crayon|Eraser|Notebook|Classroom|Recess|Report card|Map|Chalk|Science lab|Principal'],
|
| 40 |
+
['Things in a hospital', 'places', 'Doctor|Nurse|Bandage|Stethoscope|Wheelchair|Bed|Thermometer|X-ray|Ambulance|Waiting room|Gown|Syringe|Crutches|Cast|Mask|Clipboard|Monitor|Pharmacy|Elevator|Gloves|Stretcher|Plaster|Visitor|Get well card'],
|
| 41 |
+
['Things at a campsite', 'places', 'Tent|Campfire|Marshmallow|Sleeping bag|Flashlight|Lantern|Backpack|Compass|Map|Hiking boots|Canoe|Fishing rod|Cooler|Firewood|Mosquito|Bear|Stars|Owl|Pocket knife|Camp chair|Log|Trail|Hammock|Ghost story'],
|
| 42 |
+
['Things at a birthday party', 'events', 'Cake|Candles|Balloon|Present|Wrapping paper|Party hat|Card|Confetti|Streamer|Ice cream|Games|Music|Piñata|Goody bag|Banner|Clown|Magician|Bouncy castle|Song|Friends|Juice box|Pizza|Photo|Party blower'],
|
| 43 |
+
['Things at a wedding', 'events', 'Bride|Groom|Cake|Ring|Flowers|Bouquet|Veil|Vows|Dance floor|Toast|Photographer|Guest book|Confetti|Limousine|Tuxedo|Kiss|Band|First dance|Bridesmaid|Best man|Speech|Aisle|Rice|Garter'],
|
| 44 |
+
['Things at a carnival', 'events', 'Ferris wheel|Cotton candy|Carousel|Roller coaster|Clown|Balloon|Popcorn|Ring toss|Teddy bear prize|Bumper cars|Funnel cake|Face paint|Haunted house|Ticket|Juggler|Corn dog|Hall of mirrors|Candy apple|Duck pond|Strongman game|Swing ride|Fortune teller|Tent|Music'],
|
| 45 |
+
['Things at a sports game', 'events', 'Ball|Referee|Whistle|Scoreboard|Fans|Mascot|Jersey|Hot dog|Foam finger|Stadium|Goal|Coach|Bench|Trophy|Cheerleader|Popcorn|Ticket|Team flag|Wave|Halftime|Umpire|Kickoff|Jumbo screen|Locker room'],
|
| 46 |
+
['Things at a movie theater', 'events', 'Popcorn|Ticket|Screen|Seat|Trailer|Soda|Candy|3D glasses|Usher|Projector|Nachos|Cup holder|Curtain|Exit sign|Previews|Credits|Speakers|Line|Poster|Snack bar|Armrest|Butter|Dark room|Matinee'],
|
| 47 |
+
['Things at a picnic', 'events', 'Blanket|Basket|Sandwich|Lemonade|Ants|Fruit|Napkin|Cooler|Frisbee|Grass|Sunshine|Plate|Cup|Cheese|Crackers|Kite|Tree shade|Juice|Potato salad|Cookies|Watermelon|Ball|Dog|Bench'],
|
| 48 |
+
['Sports', 'sports', 'Soccer|Basketball|Tennis|Baseball|Swimming|Golf|Hockey|Volleyball|Rugby|Cricket|Badminton|Boxing|Skiing|Surfing|Cycling|Bowling|Gymnastics|Wrestling|Table tennis|Archery|Rowing|Fencing|Skateboarding|Track running'],
|
| 49 |
+
['Things with wheels', 'transport', 'Car|Bicycle|Skateboard|Bus|Truck|Scooter|Wheelchair|Shopping cart|Train|Wagon|Stroller|Tractor|Motorbike|Roller skate|Wheelbarrow|Unicycle|Go-kart|Suitcase|Office chair|Tricycle|Fire truck|Ambulance|Tank|Pram'],
|
| 50 |
+
['Things that fly', 'transport', 'Airplane|Helicopter|Bird|Kite|Balloon|Butterfly|Rocket|Drone|Bee|Bat|Glider|Paper plane|Hot air balloon|Frisbee|Dragon|Blimp|Parachute|Superhero|Jet|Dandelion seed|Firefly|Owl|Mosquito|Spaceship'],
|
| 51 |
+
['Things that float', 'transport', 'Boat|Raft|Duck|Leaf|Cork|Beach ball|Life jacket|Pool noodle|Iceberg|Log|Buoy|Canoe|Surfboard|Rubber ring|Lily pad|Bubble|Feather|Swan|Kayak|Sponge|Coconut|Paper boat|Inflatable|Sailboat'],
|
| 52 |
+
['Ways to travel', 'transport', 'Walking|Car|Train|Plane|Bus|Bicycle|Boat|Taxi|Subway|Scooter|Horse|Motorbike|Ferry|Cable car|Helicopter|Skateboard|Rollerblades|Hot air balloon|Camel|Tram|Rickshaw|Sled|Hitchhiking|Cruise ship'],
|
| 53 |
+
['Jobs', 'people', 'Teacher|Doctor|Firefighter|Chef|Pilot|Farmer|Police officer|Nurse|Dentist|Artist|Builder|Scientist|Vet|Mail carrier|Mechanic|Astronaut|Baker|Plumber|Librarian|Lifeguard|Hairdresser|Electrician|Gardener|Zookeeper'],
|
| 54 |
+
['Things a superhero needs', 'fantasy', 'Cape|Mask|Secret identity|Super strength|Sidekick|Gadget belt|Hideout|Logo|Boots|Flying|Villain|Signal light|Costume|Gloves|Shield|Catchphrase|Utility belt|X-ray vision|Speed|Invisibility|Team|Origin story|Vehicle|Courage'],
|
| 55 |
+
['Fairy tale things', 'fantasy', 'Castle|Dragon|Princess|Prince|Witch|Glass slipper|Magic wand|Frog|Giant|Beanstalk|Tower|Pumpkin carriage|Poison apple|Mirror|Wolf|Three bears|Gingerbread house|Fairy godmother|Spinning wheel|Troll|Bridge|Crown|Enchanted forest|Wish'],
|
| 56 |
+
['Mythical creatures', 'fantasy', 'Dragon|Unicorn|Mermaid|Phoenix|Griffin|Centaur|Troll|Fairy|Elf|Goblin|Yeti|Pegasus|Kraken|Sphinx|Minotaur|Gnome|Leprechaun|Cyclops|Hydra|Werewolf|Sea serpent|Bigfoot|Pixie|Ogre'],
|
| 57 |
+
['Pirate things', 'fantasy', 'Ship|Treasure|Map|Parrot|Eye patch|Hook|Plank|Cannon|Compass|Captain|Flag|Telescope|Island|Gold coins|Sword|Anchor|Crew|Hat|Barrel|Wooden leg|Chest|Sail|Rope|Shipwreck'],
|
| 58 |
+
['Space things', 'science', 'Planet|Star|Moon|Rocket|Astronaut|Comet|Galaxy|Telescope|Asteroid|Alien|Satellite|Black hole|Space station|Meteor|Sun|Mars|Saturn rings|Spacesuit|Milky Way|Orbit|Gravity|Launch pad|Nebula|Crater'],
|
| 59 |
+
['Weather', 'science', 'Rain|Snow|Sunshine|Wind|Thunder|Lightning|Fog|Hail|Rainbow|Tornado|Hurricane|Cloud|Drizzle|Frost|Heatwave|Sleet|Storm|Blizzard|Breeze|Mist|Monsoon|Dew|Gust|Sunrise'],
|
| 60 |
+
['Science lab things', 'science', 'Microscope|Test tube|Beaker|Goggles|Lab coat|Magnet|Bunsen burner|Thermometer|Petri dish|Scale|Pipette|Flask|Gloves|Battery|Telescope|Prism|Funnel|Notebook|Specimen|Timer|Experiment|Magnifying glass|Robot|Circuit'],
|
| 61 |
+
['Shapes', 'science', 'Circle|Square|Triangle|Rectangle|Star|Heart|Oval|Diamond|Hexagon|Pentagon|Octagon|Cube|Sphere|Pyramid|Cylinder|Cone|Crescent|Arrow|Spiral|Cross|Trapezoid|Zigzag|Rhombus|Wave'],
|
| 62 |
+
['Colors', 'science', 'Red|Blue|Green|Yellow|Purple|Orange|Pink|Black|White|Brown|Gray|Gold|Silver|Turquoise|Navy|Lime|Maroon|Beige|Lavender|Coral|Teal|Crimson|Mint|Peach'],
|
| 63 |
+
['Things in space or the sky', 'science', 'Cloud|Sun|Moon|Star|Rainbow|Airplane|Bird|Kite|Lightning|Comet|Satellite|Hot air balloon|Shooting star|Fireworks|Aurora|Planet|Drone|Helicopter|Contrail|Balloon|Eclipse|Meteor|Constellation|Smoke'],
|
| 64 |
+
['Body parts', 'people', 'Hand|Foot|Nose|Ear|Elbow|Knee|Shoulder|Eyebrow|Thumb|Toe|Chin|Wrist|Ankle|Hip|Neck|Heel|Cheek|Forehead|Lip|Tongue|Knuckle|Belly button|Eyelash|Fingernail'],
|
| 65 |
+
['Clothes', 'things', 'Shirt|Jeans|Dress|Jacket|Sweater|Socks|Shorts|Skirt|Hoodie|Scarf|Gloves|Hat|Coat|Pajamas|Raincoat|Swimsuit|Tie|Vest|Overalls|Leggings|Cardigan|Uniform|Apron|Poncho'],
|
| 66 |
+
['Shoes', 'things', 'Sneaker|Boot|Sandal|Slipper|Flip-flop|High heel|Loafer|Clog|Rain boot|Ballet flat|Hiking boot|Football cleat|Ski boot|Moccasin|Platform shoe|Tap shoe|Wedge|Espadrille|Snow boot|Dress shoe|Gardening clog|Roller skate|Mule|Water shoe'],
|
| 67 |
+
['Accessories', 'things', 'Watch|Necklace|Bracelet|Ring|Earrings|Sunglasses|Belt|Handbag|Backpack|Hat|Scarf|Hair clip|Headband|Wallet|Umbrella|Bow tie|Brooch|Anklet|Gloves|Cufflinks|Tiara|Keychain|Fanny pack|Beanie'],
|
| 68 |
+
['Things in a backpack', 'things', 'Water bottle|Notebook|Pencil case|Snack|Lunchbox|Keys|Phone|Headphones|Book|Charger|Jacket|Umbrella|Tissues|Sunscreen|Wallet|Calculator|Folder|Hand sanitizer|Gym clothes|Map|Granola bar|Flashlight|Earbuds|Planner'],
|
| 69 |
+
['Things that are cold', 'things', 'Ice cube|Snow|Freezer|Ice cream|Popsicle|Penguin|Glacier|Igloo|Iceberg|Winter|Frost|Snowman|Cold drink|Fridge|Ice rink|Polar bear|Hailstone|Icicle|Slushie|Arctic|Ice pack|Mountain top|Frozen lake|Snowball'],
|
| 70 |
+
['Things that are hot', 'things', 'Sun|Fire|Oven|Lava|Coffee|Soup|Desert|Stove|Kettle|Volcano|Candle|Sauna|Chili pepper|Toaster|Iron|Hot tub|Barbecue|Radiator|Campfire|Hair dryer|Tea|Fireplace|Summer|Hot chocolate'],
|
| 71 |
+
['Things that are sticky', 'things', 'Glue|Honey|Tape|Gum|Syrup|Jam|Caramel|Sticker|Slime|Cotton candy|Sap|Mud|Peanut butter|Spider web|Band-aid|Toffee|Marshmallow|Sticky note|Lollipop|Jelly|Velcro|Taffy|Maple syrup|Paint'],
|
| 72 |
+
['Things that are round', 'things', 'Ball|Coin|Plate|Wheel|Orange|Clock|Moon|Button|Pizza|Donut|Cookie|Ring|Bubble|Globe|Marble|Tire|Pancake|Frisbee|Eyeball|Pearl|Hula hoop|Lollipop|Drum|Steering wheel'],
|
| 73 |
+
['Things that are soft', 'things', 'Pillow|Blanket|Teddy bear|Cotton wool|Feather|Kitten|Marshmallow|Cloud|Sponge|Slippers|Towel|Bunny|Moss|Velvet|Fleece|Duvet|Bread|Sweater|Puppy fur|Cushion|Beanbag|Scarf|Robe|Foam'],
|
| 74 |
+
['Things that are loud', 'things', 'Thunder|Drum|Siren|Fireworks|Alarm clock|Trumpet|Lion roar|Baby crying|Vacuum|Jet engine|Concert|Whistle|Doorbell|Car horn|Rooster|Stadium crowd|Blender|Lawn mower|Train|Smoke alarm|Megaphone|Dog bark|Motorbike|Thunderclap'],
|
| 75 |
+
['Things with buttons', 'things', 'Remote|Phone|Elevator|Shirt|Keyboard|Calculator|Microwave|Doorbell|Game controller|Coat|Camera|Washing machine|Car radio|Vending machine|Cash register|Toaster|Jeans|Alarm clock|Remote car|Blender|Piano|Mouse|Crosswalk|Jukebox'],
|
| 76 |
+
['Things with a screen', 'things', 'Phone|Television|Laptop|Tablet|Smartwatch|ATM|Car dashboard|Microwave|Camera|Game console|Monitor|Calculator|Cinema|Billboard|Vending machine|Thermostat|Fitness tracker|E-reader|Self checkout|Projector|Scoreboard|Baby monitor|GPS|Arcade machine'],
|
| 77 |
+
['Things that have keys', 'things', 'Piano|Keyboard|Door|Car|Lock|Map|Typewriter|Safe|Diary|Treasure chest|Calculator|Locker|Padlock|Organ|Accordion|Mailbox|Bike lock|House|Suitcase|Laptop|Music box|Cabinet|Clock|Phone'],
|
| 78 |
+
['Musical instruments', 'music', 'Piano|Guitar|Drum|Violin|Trumpet|Flute|Saxophone|Harp|Cello|Clarinet|Ukulele|Banjo|Accordion|Harmonica|Xylophone|Tambourine|Trombone|Bagpipes|Triangle|Tuba|Recorder|Maracas|Bongo|Organ'],
|
| 79 |
+
['Things at a concert', 'music', 'Stage|Singer|Microphone|Guitar|Speakers|Lights|Crowd|Ticket|T-shirt|Encore|Drummer|Band|Smoke machine|Security|Glow stick|Setlist|Backstage pass|Earplugs|Merch stand|Front row|Applause|Spotlight|Confetti|Tour bus'],
|
| 80 |
+
['Dance moves', 'music', 'Twist|Moonwalk|Robot|Cha-cha|Salsa|Tango|Waltz|Shuffle|Spin|Hip shake|Jumping jack|Chicken dance|Conga line|Floss|Cartwheel|Splits|Twirl|Tap step|Disco point|Head bob|Grapevine|Swing|Limbo|Hokey cokey'],
|
| 81 |
+
['Board and card game things', 'games', 'Dice|Cards|Board|Pawn|Timer|Spinner|Score pad|Token|Rule book|Dealer|Chess knight|Checkers piece|Draw pile|Joker|Ace|Queen|Hourglass|Bingo card|Domino|Jigsaw piece|Play money|Game box|Turn|Winner'],
|
| 82 |
+
['Toys', 'games', 'Doll|Teddy bear|Yo-yo|Ball|Kite|Puzzle|Building blocks|Toy car|Jump rope|Spinning top|Robot|Action figure|Rubber duck|Slinky|Rocking horse|Water gun|Marbles|Play dough|Train set|Bubbles|Hula hoop|Puppet|Dollhouse|Stuffed animal'],
|
| 83 |
+
['Video game things', 'games', 'Controller|Level|Boss|Health bar|Power-up|Coin|Checkpoint|Map|Joystick|Score|Lives|Avatar|Respawn|Treasure chest|Inventory|Quest|Save point|Pixel|Headset|Loading screen|Game over|High score|Cheat code|Tutorial'],
|
| 84 |
+
['Things in a park', 'places', 'Tree|Bench|Pond|Duck|Grass|Path|Swing|Squirrel|Picnic table|Fountain|Dog walker|Kite|Jogger|Flower bed|Statue|Bridge|Ice cream van|Bike|Frisbee|Bandstand|Trash can|Gate|Bird|Leaf pile'],
|
| 85 |
+
['Things in a city', 'places', 'Skyscraper|Traffic light|Taxi|Bus stop|Sidewalk|Crosswalk|Subway|Billboard|Street lamp|Cafe|Museum|Bridge|Park|Apartment|Fire hydrant|Pigeon|Food truck|Mall|Statue|Parking lot|Library|Tower|Bike lane|Train station'],
|
| 86 |
+
['Things on a farm', 'places', 'Barn|Tractor|Hay|Silo|Fence|Scarecrow|Field|Cow|Chicken coop|Pig pen|Farmer|Pitchfork|Trough|Windmill|Orchard|Corn|Pumpkin patch|Well|Muddy boots|Rooster|Egg basket|Sheepdog|Stable|Gate'],
|
| 87 |
+
['Things in the mountains', 'places', 'Peak|Snow|Goat|Cabin|Ski lift|Trail|Waterfall|Cliff|Eagle|Pine tree|Cave|Hiker|Glacier|Lake|Rope|Valley|Avalanche|Marmot|Campsite|Summit flag|Rock|Fog|Bear|Hot spring'],
|
| 88 |
+
['Things in a forest', 'places', 'Tree|Mushroom|Deer|Owl|Fox|Moss|Log|Path|Pinecone|Squirrel|Fern|Stream|Woodpecker|Acorn|Bear|Leaves|Badger|Wolf|Berries|Cabin|Hedgehog|Spider web|Firefly|Bridge'],
|
| 89 |
+
['Things in the desert', 'places', 'Sand|Cactus|Camel|Oasis|Scorpion|Snake|Sun|Dune|Sandstorm|Lizard|Pyramid|Tumbleweed|Mirage|Vulture|Canyon|Skull|Rattlesnake|Palm tree|Tent|Water bottle|Coyote|Sunhat|Jackrabbit|Rock'],
|
| 90 |
+
['Things under the sea', 'places', 'Coral reef|Shipwreck|Submarine|Diver|Seaweed|Treasure|Shell|Pearl|Shark|Octopus|Anchor|Sea turtle|Sand|Bubbles|Jellyfish|Starfish|Kelp|Clam|Anglerfish|Whale|Snorkel|Crab|Seahorse|Eel'],
|
| 91 |
+
['Countries', 'places', 'Canada|Mexico|Brazil|France|Japan|China|India|Australia|Egypt|Italy|Spain|Germany|Kenya|Peru|Norway|Greece|Thailand|Ireland|Argentina|Nigeria|Vietnam|Iceland|Morocco|New Zealand'],
|
| 92 |
+
['Famous landmarks', 'places', 'Eiffel Tower|Great Wall|Pyramids|Statue of Liberty|Big Ben|Colosseum|Taj Mahal|Golden Gate Bridge|Mount Everest|Stonehenge|Sydney Opera House|Grand Canyon|Niagara Falls|Machu Picchu|Leaning Tower|Mount Rushmore|Christ the Redeemer|Great Barrier Reef|Acropolis|Sagrada Familia|Petra|Angkor Wat|Mount Fuji|Brandenburg Gate'],
|
| 93 |
+
['Hobbies', 'people', 'Painting|Reading|Gardening|Cooking|Knitting|Fishing|Hiking|Photography|Dancing|Singing|Chess|Baking|Camping|Drawing|Bird watching|Yoga|Skateboarding|Pottery|Writing|Puzzles|Origami|Collecting stamps|Juggling|Sewing'],
|
| 94 |
+
['Things you do in the morning', 'people', 'Wake up|Brush teeth|Shower|Get dressed|Make bed|Eat breakfast|Make coffee|Stretch|Snooze alarm|Comb hair|Feed the pet|Pack lunch|Check weather|Walk the dog|Open curtains|Yawn|Water plants|Catch the bus|Read news|Tie shoes|Drink water|Say good morning|Find keys|Jog'],
|
| 95 |
+
['Things you do at a sleepover', 'people', 'Pillow fight|Watch a movie|Tell ghost stories|Eat popcorn|Paint nails|Build a fort|Play games|Stay up late|Truth or dare|Shadow puppets|Midnight snack|Sleeping bag|Make pancakes|Dance party|Karaoke|Brush hair|Whisper secrets|Flashlight tag|Card games|Face masks|Giggle|Board game|Read comics|Fall asleep'],
|
| 96 |
+
['Feelings', 'people', 'Happy|Sad|Angry|Excited|Nervous|Surprised|Bored|Proud|Scared|Shy|Calm|Confused|Jealous|Grateful|Lonely|Silly|Tired|Hopeful|Grumpy|Brave|Embarrassed|Curious|Relieved|Cheerful'],
|
| 97 |
+
['Family members', 'people', 'Mother|Father|Sister|Brother|Grandma|Grandpa|Aunt|Uncle|Cousin|Baby|Niece|Nephew|Twin|Stepdad|Stepmom|Godmother|Great-grandma|Son|Daughter|In-law|Toddler|Godfather|Half-brother|Half-sister'],
|
| 98 |
+
['Things a baby needs', 'people', 'Diaper|Bottle|Pacifier|Crib|Rattle|Stroller|Bib|Blanket|Car seat|High chair|Baby food|Teddy bear|Onesie|Wipes|Lullaby|Mobile|Teether|Bath toy|Baby monitor|Sling|Socks|Hat|Changing mat|Hugs'],
|
| 99 |
+
['Holidays and celebrations', 'events', 'Birthday|New Year|Halloween|Thanksgiving|Valentines Day|Easter|Christmas|Hanukkah|Diwali|Eid|Lunar New Year|Mothers Day|Fathers Day|Graduation|Anniversary|April Fools|Earth Day|Independence Day|Carnival|Saint Patricks Day|Baby shower|Housewarming|Retirement party|Midsummer'],
|
| 100 |
+
['Halloween things', 'events', 'Pumpkin|Ghost|Witch|Costume|Candy|Bat|Spider web|Skeleton|Black cat|Haunted house|Mask|Cauldron|Broomstick|Jack-o-lantern|Trick or treat|Mummy|Vampire|Zombie|Full moon|Owl|Scarecrow|Candy corn|Graveyard|Spell book'],
|
| 101 |
+
['Winter things', 'events', 'Snowman|Mittens|Scarf|Sled|Hot chocolate|Ice skates|Snowflake|Fireplace|Igloo|Penguin|Icicle|Woolly hat|Snowball|Ski|Blanket|Frost|Boots|Snow angel|Reindeer|Cocoa|Soup|Snowplough|Earmuffs|Ice rink'],
|
| 102 |
+
['Summer things', 'events', 'Sunscreen|Beach|Ice cream|Swimming pool|Sunglasses|Flip-flops|Barbecue|Lemonade|Fan|Watermelon|Camping|Road trip|Popsicle|Sprinkler|Fireflies|Sunburn|Hammock|Water fight|Picnic|Shorts|Sandcastle|Holiday|Fireworks|Lazy river'],
|
| 103 |
+
['Spring things', 'events', 'Flowers|Rain|Butterfly|Tulip|Baby chick|Lamb|Rainbow|Blossom|Bird nest|Umbrella|Kite|Easter egg|Gardening|Daffodil|Bumblebee|Puddle|Seedling|Green grass|Bunny|Picnic|Spring cleaning|Sunshine|Rain boots|Frog'],
|
| 104 |
+
['Autumn things', 'events', 'Leaves|Pumpkin|Acorn|Apple picking|Scarecrow|Rake|Sweater|Squirrel|Hay ride|Corn maze|Cider|Mushroom|Bonfire|Rain|Boots|Pie|Harvest|Geese flying south|Chestnut|Scarf|Wind|Owl|Fog|Candle'],
|
| 105 |
+
['Things you can fold', 'things', 'Paper|Towel|Shirt|Map|Chair|Umbrella|Napkin|Blanket|Letter|Paper plane|Origami crane|Table|Bed sheet|Fan|Laptop|Wallet|Tent|Stroller|Sleeping bag|Socks|Cardboard box|Ironing board|Arms|Card'],
|
| 106 |
+
['Things you can open', 'things', 'Door|Window|Box|Book|Jar|Present|Bottle|Laptop|Umbrella|Eyes|Mouth|Fridge|Curtains|Envelope|Gate|Lock|Suitcase|Bag of chips|Can|Oven|Treasure chest|Drawer|Wallet|App'],
|
| 107 |
+
['Things you can throw', 'things', 'Ball|Frisbee|Paper plane|Snowball|Dart|Boomerang|Dice|Pillow|Stone|Confetti|Water balloon|Javelin|Bean bag|Horseshoe|Fishing line|Bowling ball|Discus|Party|Coin|Bouquet|Stick|Pancake|Ring|Paper ball'],
|
| 108 |
+
['Things you can climb', 'things', 'Tree|Ladder|Stairs|Mountain|Rock wall|Rope|Fence|Hill|Monkey bars|Bunk bed|Tower|Cliff|Lighthouse|Volcano|Sand dune|Ship mast|Jungle gym|Climbing frame|Escalator|Wall|Pyramid|Treehouse|Bleachers|Haystack'],
|
| 109 |
+
['Things that bounce', 'things', 'Ball|Trampoline|Kangaroo|Bouncy castle|Rubber ball|Spring|Basketball|Pogo stick|Frog|Jelly|Rabbit|Tennis ball|Space hopper|Yo-yo|Slinky|Bungee cord|Gymnast|Dice|Raindrop|Ping pong ball|Bed|Grasshopper|Balloon|Echo'],
|
| 110 |
+
['Things that grow', 'things', 'Tree|Flower|Hair|Baby|Puppy|Grass|Beanstalk|Fingernail|Crystal|Mushroom|Pumpkin|Moss|Bamboo|Sunflower|Garden|Savings|Seedling|Mold|Ivy|Kitten|Tadpole|Snowball|Shadow|Bread dough'],
|
| 111 |
+
['Things that melt', 'things', 'Ice|Snowman|Chocolate|Butter|Candle|Ice cream|Cheese|Icicle|Popsicle|Glacier|Crayon|Marshmallow|Wax|Frost|Caramel|Snowflake|Lollipop|Jelly|Gummy bear|Lava rock|Sugar|Frosting|Snow|Ice sculpture'],
|
| 112 |
+
['Things in a toy box', 'games', 'Ball|Doll|Blocks|Car|Puzzle|Crayon|Robot|Dinosaur|Teddy|Yo-yo|Marble|Puppet|Train|Spinning top|Toy phone|Slinky|Toy sword|Dress-up hat|Drum|Bubble wand|Kaleidoscope|Plastic fruit|Magic set|Stuffed rabbit'],
|
| 113 |
+
['Things that smell good', 'things', 'Flowers|Fresh bread|Cookies|Coffee|Popcorn|Candle|Perfume|Rain|Cut grass|Lavender|Vanilla|Pine tree|Cinnamon|Orange peel|Baby powder|Clean laundry|Bacon|Rose|Chocolate|Soap|Campfire|Lemon|Mint|Pizza'],
|
| 114 |
+
['Things that are yellow', 'things', 'Banana|Sun|Lemon|School bus|Duck|Corn|Cheese|Sunflower|Taxi|Bee|Butter|Egg yolk|Pineapple|Mustard|Canary|Rubber duck|Highlighter|Daffodil|Pencil|Gold|Star|Smiley face|Raincoat|Sand'],
|
| 115 |
+
['Things that are green', 'things', 'Grass|Frog|Leaf|Lime|Broccoli|Cucumber|Pickle|Emerald|Turtle|Cactus|Pea|Kiwi|Dollar bill|Moss|Clover|Avocado|Tree|Lettuce|Crocodile|Celery|Parrot|Traffic light|Mint|Dinosaur'],
|
| 116 |
+
['Things that are red', 'things', 'Apple|Strawberry|Fire truck|Rose|Tomato|Cherry|Stop sign|Ladybug|Heart|Lobster|Lips|Chili pepper|Poppy|Brick|Ruby|Cardinal bird|Watermelon|Lipstick|Mailbox|Raspberry|Barn|Ketchup|Santa suit|Fire'],
|
| 117 |
+
['Things that are blue', 'things', 'Sky|Ocean|Blueberry|Jeans|Whale|Sapphire|Bluebird|Ice|Swimming pool|Police car|Lake|Blue jay|Denim|Navy uniform|Forget-me-not|Peacock|Ink|Planet Earth|Dolphin|Butterfly|Glacier|Lagoon|Vein|Recycling bin'],
|
| 118 |
+
['Things in a car', 'transport', 'Steering wheel|Seat belt|Mirror|Radio|Horn|Glove box|Airbag|Gear stick|Pedal|Cup holder|Windscreen|Wipers|Map|Spare tire|Air freshener|Sun visor|Dashboard|Back seat|Trunk|Headlights|Car keys|Indicator|Handbrake|Charger'],
|
| 119 |
+
['Things on a train journey', 'transport', 'Ticket|Conductor|Seat|Window|Platform|Snack trolley|Tunnel|Luggage rack|Timetable|Whistle|Station|Tracks|Carriage|Sleeper berth|Dining car|Bridge|View|Book|Headphones|Delay|Map|Signal|Passenger|Ticket machine'],
|
| 120 |
+
['Things in a library', 'places', 'Book|Shelf|Librarian|Library card|Quiet sign|Desk|Computer|Magazine|Newspaper|Atlas|Dictionary|Story time|Bookmark|Reading lamp|Returns box|Encyclopedia|Comic|Map|Globe|Beanbag|Study room|Due date|Catalog|Printer'],
|
| 121 |
+
['Things at a restaurant', 'places', 'Menu|Waiter|Chef|Table|Bill|Tip|Napkin|Fork|Knife|Spoon|Plate|Glass|Candle|Reservation|Kitchen|Dessert|Starter|Main course|Specials board|High chair|Salt shaker|Bread basket|Booth|Takeaway box'],
|
| 122 |
+
['Things at a hotel', 'places', 'Key card|Reception|Bellhop|Lift|Room service|Pool|Minibar|Lobby|Suitcase|Towel|Do not disturb sign|Breakfast buffet|Gym|Ice machine|Bathrobe|Slippers|Concierge|Wake-up call|Pillow menu|Spa|Balcony|Check-out|Shampoo bottle|View'],
|
| 123 |
+
['Things at a museum', 'places', 'Painting|Statue|Dinosaur skeleton|Mummy|Map|Ticket|Guide|Gift shop|Display case|Fossil|Armor|Vase|Audio guide|Exhibit|Rope barrier|Bench|Sculpture|Gallery|Ancient coin|Model ship|Meteorite|Tapestry|Crown|Café'],
|
| 124 |
+
['Things in an office', 'places', 'Desk|Computer|Printer|Stapler|Paper clip|Coffee mug|Chair|Calendar|Phone|Whiteboard|Sticky note|Filing cabinet|Meeting room|Water cooler|Laptop|Pen|Notepad|Badge|Lamp|Plant|Keyboard|Calculator|Shredder|Coat rack'],
|
| 125 |
+
['Things a detective uses', 'people', 'Magnifying glass|Notebook|Flashlight|Fingerprint|Clue|Hat|Coat|Map|Camera|Pipe|Badge|Suspect list|Footprint|Disguise|Binoculars|Evidence bag|Tape measure|Torch|Secret code|Interview|Alibi|Pocket watch|Witness|Gloves'],
|
| 126 |
+
['Things in a magic show', 'fantasy', 'Wand|Top hat|Rabbit|Cape|Cards|Coin|Dove|Box|Saw|Assistant|Curtain|Smoke|Rope|Scarf|Ring|Spotlight|Applause|Trap door|Mirror|Crystal ball|Disappearing act|Volunteer|Drum roll|Bow'],
|
| 127 |
+
['Things in a castle', 'fantasy', 'Throne|Tower|Moat|Drawbridge|Knight|King|Queen|Dungeon|Banner|Armor|Sword|Shield|Crown|Banquet hall|Spiral staircase|Gate|Torch|Dragon|Jester|Tapestry|Chandelier|Courtyard|Guard|Secret passage'],
|
| 128 |
+
['Things in a spooky house', 'fantasy', 'Cobweb|Creaky door|Ghost|Candle|Bat|Portrait|Attic|Basement|Black cat|Skeleton|Owl|Old clock|Moonlight|Thunder|Dusty piano|Coffin|Rocking chair|Raven|Lantern|Secret door|Footsteps|Mirror|Fog|Rat'],
|
| 129 |
+
['Things in a robot lab', 'science', 'Robot|Wires|Battery|Laptop|Circuit board|Screwdriver|Sensor|Antenna|Motor|Joystick|Remote control|Wheels|Solder|Gears|Blueprint|LED|Laser|Camera|Scientist|Goggles|Power switch|Charging dock|Code|Spare parts'],
|
| 130 |
+
['Things on a pizza night', 'food', 'Pizza|Dough|Oven|Cheese|Sauce|Pizza cutter|Box|Delivery|Garlic bread|Napkin|Soda|Movie|Salad|Dip|Crust|Toppings|Slice|Paper plate|Friends|Board game|Chili flakes|Ice cream|Couch|Cookies'],
|
| 131 |
+
['Things you find in a purse or wallet', 'things', 'Keys|Coins|Receipt|Card|Lipstick|Tissues|Phone|Gum|Mirror|Pen|Photo|Hand cream|Mints|Sunglasses|Hair tie|Charger|Ticket|Coupon|Band-aid|Plaster|Comb|Library card|Snack|Notebook'],
|
| 132 |
+
['Things you can ride', 'transport', 'Bike|Horse|Elephant|Camel|Roller coaster|Skateboard|Bus|Train|Wave|Elevator|Carousel|Scooter|Sled|Pony|Motorbike|Jet ski|Zip line|Ferris wheel|Escalator|Gondola|Surfboard|Snowboard|Donkey|Hot air balloon'],
|
| 133 |
+
['Things that tick or ring', 'things', 'Clock|Alarm|Watch|Phone|Doorbell|Bicycle bell|Kitchen timer|Church bell|School bell|Metronome|Ice cream van chime|Wind chime|Cuckoo clock|Grandfather clock|Fire alarm|Bell on a cat|Sleigh bells|Shop door bell|Hourglass|Stopwatch|Microwave|Egg timer|Telephone|Boxing bell'],
|
| 134 |
+
['Things with wings', 'animals', 'Bird|Butterfly|Bee|Airplane|Bat|Dragon|Angel|Fairy|Dragonfly|Moth|Pegasus|Ladybug|Ostrich|Penguin|Chicken|Owl|Glider|Wasp|Fly|Phoenix|Griffin|Mosquito|Beetle|Parrot'],
|
| 135 |
+
['Things with tails', 'animals', 'Dog|Cat|Monkey|Mouse|Kite|Comet|Fox|Horse|Peacock|Lizard|Squirrel|Whale|Mermaid|Dragon|Airplane|Kangaroo|Beaver|Scorpion|Tadpole|Crocodile|Shark|Rat|Lemur|Tiger'],
|
| 136 |
+
['Things with shells', 'animals', 'Turtle|Snail|Crab|Egg|Oyster|Clam|Tortoise|Lobster|Peanut|Walnut|Mussel|Armadillo|Hermit crab|Scallop|Coconut|Pistachio|Shrimp|Beetle|Barnacle|Conch|Sea urchin|Chestnut|Taco|Almond'],
|
| 137 |
+
['Things that are fast', 'things', 'Cheetah|Rocket|Race car|Jet|Lightning|Bullet train|Falcon|Sprinter|Speedboat|Motorbike|Hare|Hummingbird|Roller coaster|Comet|Wind|Internet|Greyhound|Horse|Ostrich|Sailfish|Skier|Formula car|Shooting star|Swordfish'],
|
| 138 |
+
['Things that are slow', 'things', 'Snail|Sloth|Turtle|Tortoise|Glacier|Traffic jam|Starfish|Koala|Slug|Queue|Dial-up internet|Snow melting|Tree growing|Old elevator|Barge|Caterpillar|Manatee|Sunday morning|Dripping tap|Paint drying|Mule|Rowing boat|Line at the bank|Moon rising'],
|
| 139 |
+
['Things that are tiny', 'things', 'Ant|Seed|Grain of sand|Button|Pebble|Pin|Freckle|Crumb|Atom|Bead|Flea|Raindrop|Dust|Pea|Baby mouse|Ladybug|Tadpole|Diamond|Paper clip|Microchip|Sprinkle|Tic tac|Thimble|Germ'],
|
| 140 |
+
['Things that are huge', 'things', 'Whale|Elephant|Mountain|Skyscraper|Planet|Sun|Ocean|Dinosaur|Ship|Giant|Stadium|Airplane|Volcano|Redwood tree|Glacier|Pyramid|Cruise liner|Iceberg|Galaxy|Desert|Castle|Dam|Bridge|Bus'],
|
| 141 |
+
['Things in a fairground stall', 'games', 'Prize|Ring toss|Duck|Hoop|Darts|Coconut shy|Ticket|Plush toy|Goldfish bag|Bell|Water pistol|Cans|Ball|Mallet|Wheel of fortune|Stall owner|Lights|Ribbon|Trophy|Bucket|Balloon|Pop gun|Hook a duck|Lucky dip'],
|
| 142 |
+
['Things you say hello to', 'people', 'Friend|Neighbor|Teacher|Dog|Baby|Grandma|Mail carrier|Cashier|Bus driver|Classmate|Cat|Coach|Doctor|Barista|New kid|Librarian|Waiter|Cousin|Boss|Crossing guard|Pen pal|Shopkeeper|Duck in a pond|Morning sun'],
|
| 143 |
+
['Things that make you laugh', 'people', 'Joke|Clown|Tickle|Funny video|Comedy show|Puppy|Prank|Pun|Silly face|Cartoon|Blooper|Whoopee cushion|Riddle|Funny hat|Knock-knock joke|Sitcom|Bad dance|Comic strip|Impressions|Funny voice|Meme|Slip on a banana|Laughing friend|Hiccups'],
|
| 144 |
+
['Things you take on holiday', 'things', 'Suitcase|Passport|Sunscreen|Camera|Swimsuit|Sunglasses|Map|Hat|Book|Charger|Toothbrush|Snacks|Tickets|Pillow|Flip-flops|Guidebook|Adapter|Headphones|Water bottle|Medicine|Money|Jacket|Beach towel|Journal'],
|
| 145 |
+
['Things with numbers', 'things', 'Clock|Phone|Calculator|Calendar|Ruler|Dice|Thermometer|Scale|Speedometer|Ticket|House|Jersey|Money|Remote|License plate|Elevator|Receipt|Scoreboard|Map grid|Bus|Lottery ticket|Recipe|Measuring cup|Birthday cake candle'],
|
| 146 |
+
['Superpowers', 'fantasy', 'Flying|Invisibility|Super strength|Super speed|Telepathy|Time travel|Teleporting|X-ray vision|Shape shifting|Healing|Talking to animals|Mind control|Freezing time|Fire breath|Ice powers|Breathing underwater|Shrinking|Growing giant|Laser eyes|Force field|Weather control|Super hearing|Stretching|Cloning'],
|
| 147 |
+
['Things in a treasure chest', 'fantasy', 'Gold coins|Jewels|Crown|Pearls|Map|Necklace|Ring|Ruby|Diamond|Goblet|Silver|Dagger|Key|Scroll|Compass|Emerald|Medallion|Pocket watch|Sapphire|Bracelet|Old letter|Tiara|Gem|Bracers'],
|
| 148 |
+
['Things at the dentist', 'places', 'Dentist|Chair|Toothbrush|Floss|Mirror|Drill|Mask|Gloves|X-ray|Filling|Braces|Mouthwash|Light|Sticker|Waiting room|Bib|Cavity|Molar|Retainer|Polish|Appointment card|Sink|Free toothbrush|Smile'],
|
| 149 |
+
['Things at a bakery', 'food', 'Bread|Croissant|Cake|Baker|Oven|Flour|Muffin|Bagel|Donut|Pie|Cookie|Baguette|Rolling pin|Apron|Dough|Icing|Sprinkles|Cinnamon roll|Scone|Pretzel|Sourdough|Display case|Paper bag|Cupcake'],
|
| 150 |
+
['Things that come in pairs', 'things', 'Shoes|Socks|Gloves|Earrings|Twins|Chopsticks|Eyes|Ears|Dice|Scissors blades|Skis|Mittens|Wings|Lungs|Salt and pepper|Headphones|Drumsticks|Bookends|Oars|Glasses lenses|Knees|Swans|Dumbbells|Wedding rings'],
|
| 151 |
+
];
|