// Kenwren: make a "how well do you know me" quiz, share it, friends play, private scoreboard. (c) 2026 CyberMax. import { QUESTIONS, BY_ID, PACKS, QUIZ_LEN, cleanName, suggest, verdict } from './core.js'; import { API, SITE, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js'; import * as pass from './funpass.js'; import { initSolo, route as soloRoute, renderHome } from './solo.js'; const GAME = 'kenwren'; const PATH = '/games/kenwren/'; // canonical path on the CyberMax site (share links) const HERE = location.pathname; // in-page links work on the site and on HF copies const $ = (id) => document.getElementById(id); const SKINS = { dusk: { name: 'Dusk', a: '#2A1B3D', b: '#FFD166', free: true }, sunrise: { name: 'Sunrise', a: '#3D1F1B', b: '#FFE08A' }, lagoon: { name: 'Lagoon', a: '#0B2E3A', b: '#B8FFF0' }, bubblegum: { name: 'Bubblegum', a: '#3A1235', b: '#FFF1A8' }, paper: { name: 'Paper', a: '#EFE7D6', b: '#C2412D' } }; let mode = 'make'; // make | play let name = '', packs = ['classic'], qs = [], answers = [], i = 0, quiz = null, swaps = 0; pass.init({ game: GAME, name: 'Kenwren', perks: ['Solo levels 21-40: Memory Lane and Mind Reader', 'Deep and Spicy (clean) question packs', 'Quizzes last a year instead of 30 days', 'Sunrise, Lagoon, Bubblegum and Paper themes'] }); const install = pwa(GAME, $('install')); themeToggle($('theme')); function show(id) { document.querySelectorAll('.screen').forEach((s) => { s.hidden = s.id !== id; }); $('err').hidden = true; $('stage').scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } function err(msg) { const e = $('err'); e.textContent = msg; e.hidden = false; } const quizUrl = (id) => `${SITE}${PATH}?q=${id}`; async function api(path, init) { const r = await fetch(`${API}${path}`, init ? { ...init, headers: { 'content-type': 'application/json' } } : undefined); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.message || 'Something went wrong. Try again in a moment.'); return j; } const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); const qText = (q) => (mode === 'make' ? q.me : q.you.replaceAll('{n}', quiz.name)); // ------------------------------------------------------------------ making a quiz function renderPacks() { const el = $('packs'); el.innerHTML = ''; for (const [id, p] of Object.entries(PACKS)) { const b = document.createElement('button'); const lock = !p.free && !pass.isPro(); b.type = 'button'; b.className = 'chip'; b.setAttribute('aria-pressed', String(packs.includes(id))); b.innerHTML = `${p.name}${lock ? ' ๐Ÿ”’' : ''}`; b.onclick = () => { if (lock) { pass.open(`pack-${id}`); return; } packs = packs.includes(id) ? packs.filter((x) => x !== id) : [...packs, id]; if (!packs.length) packs = ['classic']; renderPacks(); }; el.appendChild(b); } el.hidden = mode !== 'make'; } $('start').onclick = () => { mode = 'make'; $('name-q').textContent = "What's your name?"; $('name-note').textContent = 'Friends will see this name. No email, no account.'; renderPacks(); show('s-name'); $('name').focus(); }; $('name-form').onsubmit = (e) => { e.preventDefault(); name = cleanName($('name').value); if (!name) { err('Add a name (letters and numbers).'); return; } store('kenwren:name', name); if (mode === 'make') { qs = suggest(`${Date.now()}`, packs); answers = []; i = 0; swaps = 0; count(GAME, 'play', 'make'); } else { qs = quiz.questions; answers = []; i = 0; count(GAME, 'play', 'friend'); } ask(); }; function ask() { const q = BY_ID[qs[i]]; $('bar').style.width = `${(i / QUIZ_LEN) * 100}%`; $('q-count').textContent = `Question ${i + 1} of ${QUIZ_LEN}`; $('q-text').textContent = qText(q); const box = $('opts'); box.innerHTML = ''; q.opts.forEach((o, k) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'opt'; b.textContent = o; b.setAttribute('aria-pressed', String(answers[i] === k)); b.onclick = () => pick(k); box.appendChild(b); }); $('swap').hidden = mode !== 'make'; $('back').disabled = i === 0; show('s-q'); } function pick(k) { answers[i] = k; if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ } if (i < QUIZ_LEN - 1) { i++; setTimeout(ask, 120); } else setTimeout(finish, 120); } $('back').onclick = () => { if (i > 0) { i--; ask(); } }; $('swap').onclick = () => { const used = new Set(qs); const pool = QUESTIONS.filter((q) => packs.includes(q.pack) && !used.has(q.id)); if (!pool.length) { toast('No more questions in these packs.'); return; } qs[i] = pool[(swaps++ * 7 + i) % pool.length].id; answers[i] = undefined; ask(); }; document.addEventListener('keydown', (e) => { if (!$('s-q').hidden && /^[1-4]$/.test(e.key)) pick(Number(e.key) - 1); }); async function finish() { $('bar').style.width = '100%'; if (mode === 'make') { try { const st = store('cmx-pro:fun-pass'); const j = await api('/api/quiz', { method: 'POST', body: JSON.stringify({ name, items: qs.map((q, k) => ({ q, a: answers[k] })), pass: st?.code }) }); const mine = store('kenwren:mine') || []; mine.unshift({ id: j.id, token: j.token, name, at: new Date().toISOString().slice(0, 10), expires: j.expires }); store('kenwren:mine', mine.slice(0, 30)); count(GAME, 'win', 'made'); shareScreen({ id: j.id, token: j.token, name, expires: j.expires }); install.offer(); } catch (e) { err(e.message); } } else { try { const j = await api(`/api/quiz/${quiz.id}/play`, { method: 'POST', body: JSON.stringify({ nick: name, picks: answers }) }); count(GAME, 'win', 'friend'); resultScreen(j); install.offer(); } catch (e) { err(e.message); } } } function shareScreen(m) { $('share-name').textContent = m.name; $('share-link').value = quizUrl(m.id); $('days').textContent = m.expires ? `until ${m.expires}` : '30 days'; const text = `How well do you know me? Take my 10-question quiz and see where you rank ๐Ÿ‘€`; const path = `${PATH}?q=${m.id}`; const it = intents(GAME, text, path); $('sw').href = it.whatsapp; $('sx').href = it.x; $('sb').href = it.bsky; $('share').onclick = async () => share(GAME, text, path, await canvasBlob(card(`How well do you know ${m.name}?`, 'Take my quiz', '10 questions ยท see where you rank')), `kenwren-${m.id}.png`); $('card').onclick = async () => { download(await canvasBlob(card(`How well do you know ${m.name}?`, 'Take my quiz', '10 questions ยท see where you rank')), `kenwren-${m.id}.png`); count(GAME, 'share', 'png'); }; $('results-link').href = `${HERE}?q=${m.id}&t=${m.token}`; show('s-share'); } $('copy').onclick = async () => { try { await navigator.clipboard.writeText($('share-link').value); toast('Link copied. Send it to your friends!'); } catch { $('share-link').select(); } count(GAME, 'share', 'copy'); }; ['sw', 'sx', 'sb'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sw: 'whatsapp', sx: 'x', sb: 'bsky' }[id]))); // ------------------------------------------------------------------ playing a friend's quiz function resultScreen(j) { $('r-title').textContent = `How well do you know ${j.name}?`; $('r-score').textContent = `${j.score}/${j.of}`; $('r-verdict').textContent = `${verdict(j.score)} You're #${j.rank} of ${j.board.length >= 20 ? 'the top 20' : j.board.length} on ${j.name}'s board.`; $('r-recap').innerHTML = quiz.questions.map((qid, k) => { const q = BY_ID[qid]; const ok = answers[k] === j.answers[k]; return `
  • ${ok ? 'โœ…' : 'โŒ'} ${esc(q.you.replaceAll('{n}', j.name))} ${esc(q.opts[j.answers[k]])}${ok ? '' : ` (you said ${esc(q.opts[answers[k]])})`}
  • `; }).join(''); const text = `I scored ${j.score}/10 on how well I know ${j.name} ๐Ÿ‘€ ${'๐ŸŸฉ'.repeat(j.score)}${'โฌœ'.repeat(10 - j.score)}`; $('r-share').onclick = async () => share(GAME, text, `${PATH}?q=${quiz.id}`, await canvasBlob(card(`I scored ${j.score}/10`, `on ${j.name}'s friend quiz`, verdict(j.score))), `kenwren-score.png`); $('r-make').onclick = () => { count(GAME, 'cta', 'make-own'); history.replaceState(null, '', HERE); quiz = null; $('start').click(); }; show('s-result'); } async function loadQuiz(id, token) { try { if (token) { const b = await api(`/api/quiz/${id}/board?t=${encodeURIComponent(token)}`); boardScreen(b, token); return; } quiz = await api(`/api/quiz/${id}`); mode = 'play'; $('name-q').textContent = `How well do you know ${quiz.name}?`; $('name-note').textContent = `${quiz.plays} friend${quiz.plays === 1 ? ' has' : 's have'} played. Type your name to start; ${quiz.name} will see it on the scoreboard.`; $('name').value = store('kenwren:name') || ''; renderPacks(); show('s-name'); } catch (e) { show('s-home'); err(e.message); } } // ------------------------------------------------------------------ creator's scoreboard function boardScreen(b, token) { $('b-title').textContent = `${b.name}'s scoreboard`; $('b-meta').textContent = `${b.plays} play${b.plays === 1 ? '' : 's'} ยท open until ${b.expires}`; $('b-list').innerHTML = b.board.length ? b.board.map((e) => `
  • ${esc(e.nick)} ${e.score}/10
  • `).join('') : '
  • No one has played yet. Share your link!
  • '; $('b-ans').innerHTML = b.answers.map((x) => `
  • ${esc(BY_ID[x.q].me)} ${esc(BY_ID[x.q].opts[x.a])}
  • `).join(''); $('b-refresh').onclick = () => loadQuiz(b.id, token); $('b-share').onclick = () => shareScreen({ id: b.id, token, name: b.name, expires: b.expires }); show('s-board'); } $('mine-btn').onclick = () => { const mine = (store('kenwren:mine') || []).filter((m) => !m.expires || m.expires >= new Date().toISOString().slice(0, 10)); if (!mine.length) { toast('No quizzes on this device yet. Make one!'); return; } const m = mine[0]; if (mine.length > 1) { const k = Number(prompt(mine.map((x, n) => `${n + 1}. ${x.name} (${x.at})`).join('\n') + '\n\nOpen which scoreboard?', '1')); if (!(k >= 1 && k <= mine.length)) return; history.replaceState(null, '', `${HERE}?q=${mine[k - 1].id}&t=${mine[k - 1].token}`); loadQuiz(mine[k - 1].id, mine[k - 1].token); return; } history.replaceState(null, '', `${HERE}?q=${m.id}&t=${m.token}`); loadQuiz(m.id, m.token); }; // ------------------------------------------------------------------ story card (1080ร—1350) function card(title, sub, foot) { const c = document.createElement('canvas'); c.width = 1080; c.height = 1350; const g = c.getContext('2d'); const S = SKINS[store('kenwren:skin') || 'dusk'] || SKINS.dusk; const grd = g.createLinearGradient(0, 0, 1080, 1350); grd.addColorStop(0, S.a); grd.addColorStop(1, '#11141B'); g.fillStyle = grd; g.fillRect(0, 0, 1080, 1350); g.fillStyle = S.b; g.font = '900 60px system-ui, sans-serif'; g.fillText('KENWREN', 80, 130); g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; const words = title.split(' '); let line = '', y = 420; for (const w of words) { if (g.measureText(`${line}${w} `).width > 920) { g.fillText(line, 80, y); y += 110; line = ''; } line += `${w} `; } g.fillText(line, 80, y); g.fillStyle = S.b; g.font = '800 64px system-ui, sans-serif'; g.fillText(sub, 80, y + 120); g.fillStyle = '#ffffffcc'; g.font = '600 40px system-ui, sans-serif'; g.fillText(foot, 80, y + 200); for (let k = 0; k < 10; k++) { g.fillStyle = k % 3 ? '#ffffff22' : S.b; rr(g, 80 + k * 92, 1060, 76, 76, 18); g.fill(); } g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif'; g.fillText('Free: cybermaxtools.com/games/kenwren', 80, 1290); return c; } // ------------------------------------------------------------------ themes, pass, start function renderSkins() { const el = $('skins'); el.innerHTML = ''; const cur = store('kenwren:skin') || 'dusk'; for (const [id, s] of Object.entries(SKINS)) { const b = document.createElement('button'); b.type = 'button'; b.title = `${s.name} theme${s.free ? '' : ' (Fun Pass)'}`; b.setAttribute('aria-label', b.title); b.style.background = `linear-gradient(135deg, ${s.a} 50%, ${s.b} 50%)`; b.setAttribute('aria-pressed', String(id === cur)); if (!s.free && !pass.isPro()) b.classList.add('locked'); b.onclick = () => { if (!s.free && !pass.isPro()) { pass.open('theme'); return; } store('kenwren:skin', id); document.documentElement.dataset.skin = id === 'dusk' ? '' : id; renderSkins(); }; el.appendChild(b); } } document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); }); pass.onChange((pro) => { if (!pro && (store('kenwren:skin') || 'dusk') !== 'dusk') { store('kenwren:skin', 'dusk'); document.documentElement.dataset.skin = ''; } if (!pro) packs = packs.filter((p) => PACKS[p].free); renderSkins(); renderPacks(); }); // Screenshot helper for scripts/games/render_assets.mjs and studio shots (no effect on normal play). window.__cmxShot = async (stage = 1) => { if (stage === 2) { quiz = { id: 'demo1234', name: 'Sam', questions: suggest('demo') }; mode = 'play'; answers = [0, 1, 0, 2, 1, 0, 3, 1, 0, 2]; resultScreen({ name: 'Sam', score: 8, of: 10, rank: 2, board: [{ nick: 'Alex', score: 9 }, { nick: 'Jo', score: 8 }, { nick: 'Riley', score: 6 }], answers: [0, 1, 0, 2, 1, 0, 2, 1, 3, 2] }); return; } mode = 'make'; name = 'Sam'; qs = suggest('demo'); answers = [0]; i = 1; ask(); document.querySelector('.opt').setAttribute('aria-pressed', 'true'); }; initSolo({ show, path: PATH }); $('home-btn').onclick = () => { renderHome(); show('s-home'); }; const q = new URLSearchParams(location.search); if (/^[a-z0-9]{8}$/.test(q.get('q') || '')) loadQuiz(q.get('q'), q.get('t')); else if (!soloRoute(location.search)) show('s-home');