CyberMax-tools commited on
Commit
f6d583b
·
verified ·
1 Parent(s): e924d8d

Deploy kenwren to the hub

Browse files
kenwren/cmx.js CHANGED
@@ -129,7 +129,7 @@ export function pwa(game, btn) {
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) btn.hidden = false; });
133
  window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
134
  if (btn) {
135
  btn.addEventListener('click', async () => {
@@ -137,10 +137,12 @@ export function pwa(game, btn) {
137
  deferred.prompt();
138
  const r = await deferred.userChoice.catch(() => null);
139
  if (r && r.outcome === 'accepted') btn.hidden = true;
 
140
  deferred = null;
141
  } else if (ios) {
142
  toast('Tap the Share button, then "Add to Home Screen".', 5000);
143
  count(game, 'install', 'ios-hint');
 
144
  }
145
  });
146
  }
 
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 () => {
 
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
  }
kenwren/funpass.js CHANGED
@@ -1,6 +1,6 @@
1
  // CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
2
  // Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
3
- // Monthly $2.99 · Yearly $19 · Lifetime $29.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
4
  // Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
5
  // After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
6
  // the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
@@ -15,7 +15,7 @@ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cance
15
  export const PLANS = [
16
  { id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
17
  { id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
18
- { id: 'unlock', label: 'Lifetime', price: '$29.99', per: ' once', note: 'Pay once, keep forever' },
19
  ];
20
  export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
21
 
 
1
  // CyberMax Fun Pass: ONE optional supporter pass for every CyberMax game (copied into each game's web/ folder).
2
  // Free games stay free. The pass removes the supporter nudges and unlocks extra packs, the full archive and themes.
3
+ // Monthly $2.99 · Yearly $19 · Unlock all $4.99 once (Stripe Payment Links, pack revenue/packs/stripe/fun-pass).
4
  // Buy: counted redirect https://data.cybermaxtools.com/buy/st-fun-pass-<plan>?s=game-<id> -> Stripe checkout.
5
  // After checkout Stripe sends the buyer to data.cybermaxtools.com/unlock/fun-pass?session_id=..., which checks
6
  // the Checkout Session (and, for a subscription, that it is still active) and returns to /games/ with #cmx-pro=<code>.
 
15
  export const PLANS = [
16
  { id: 'monthly', label: 'Monthly', price: '$2.99', per: '/month', note: 'Cancel any time' },
17
  { id: 'yearly', label: 'Yearly', price: '$19', per: '/year', note: 'Save 47%', best: true },
18
+ { id: 'unlock', label: 'Unlock all', price: '$4.99', per: ' once', note: 'Pay once, keep forever' },
19
  ];
20
  export const COMMON_PERKS = ['No supporter nudges, ever', 'Extra puzzle packs in every game', 'The full archive of past dailies', 'Custom colour themes'];
21
 
kenwren/game.js CHANGED
@@ -2,6 +2,7 @@
2
  import { QUESTIONS, BY_ID, PACKS, QUIZ_LEN, cleanName, suggest, verdict } from './core.js';
3
  import { API, SITE, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
4
  import * as pass from './funpass.js';
 
5
 
6
  const GAME = 'kenwren';
7
  const PATH = '/games/kenwren/'; // canonical path on the CyberMax site (share links)
@@ -11,7 +12,7 @@ const SKINS = { dusk: { name: 'Dusk', a: '#2A1B3D', b: '#FFD166', free: true },
11
  let mode = 'make'; // make | play
12
  let name = '', packs = ['classic'], qs = [], answers = [], i = 0, quiz = null, swaps = 0;
13
 
14
- pass.init({ game: GAME, name: 'Kenwren', perks: ['Deep and Spicy (clean) question packs', 'Quizzes last a year instead of 30 days', 'Sunrise, Lagoon, Bubblegum and Paper themes'] });
15
  const install = pwa(GAME, $('install'));
16
  themeToggle($('theme'));
17
 
@@ -237,5 +238,8 @@ window.__cmxShot = async (stage = 1) => {
237
  document.querySelector('.opt').setAttribute('aria-pressed', 'true');
238
  };
239
 
 
 
240
  const q = new URLSearchParams(location.search);
241
- if (/^[a-z0-9]{8}$/.test(q.get('q') || '')) loadQuiz(q.get('q'), q.get('t')); else show('s-home');
 
 
2
  import { QUESTIONS, BY_ID, PACKS, QUIZ_LEN, cleanName, suggest, verdict } from './core.js';
3
  import { API, SITE, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
4
  import * as pass from './funpass.js';
5
+ import { initSolo, route as soloRoute, renderHome } from './solo.js';
6
 
7
  const GAME = 'kenwren';
8
  const PATH = '/games/kenwren/'; // canonical path on the CyberMax site (share links)
 
12
  let mode = 'make'; // make | play
13
  let name = '', packs = ['classic'], qs = [], answers = [], i = 0, quiz = null, swaps = 0;
14
 
15
+ 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'] });
16
  const install = pwa(GAME, $('install'));
17
  themeToggle($('theme'));
18
 
 
238
  document.querySelector('.opt').setAttribute('aria-pressed', 'true');
239
  };
240
 
241
+ initSolo({ show, path: PATH });
242
+ $('home-btn').onclick = () => { renderHome(); show('s-home'); };
243
  const q = new URLSearchParams(location.search);
244
+ if (/^[a-z0-9]{8}$/.test(q.get('q') || '')) loadQuiz(q.get('q'), q.get('t'));
245
+ else if (!soloRoute(location.search)) show('s-home');
kenwren/index.html CHANGED
@@ -1,11 +1,11 @@
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>Kenwren: how well do your friends know you? Free friend quiz | CyberMax</title>
4
- <meta name="description" content="Make a free 'how well do you know me' quiz in a minute: answer 10 fun questions about yourself, share the link, and see who knows you best on your private scoreboard. No sign-up, no app.">
5
  <link rel="canonical" href="https://cybermaxtools.com/games/kenwren/">
6
  <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
- <meta property="og:title" content="How well do you know me? Take my quiz">
8
- <meta property="og:description" content="10 questions. Guess my answers. See where you rank among my friends.">
9
  <meta property="og:url" content="https://cybermaxtools.com/games/kenwren/">
10
  <meta property="og:image" content="https://cybermaxtools.com/games/kenwren/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
11
  <meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/kenwren/og.png">
@@ -15,19 +15,54 @@
15
  <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Kenwren">
16
  <link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
17
  <script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('kenwren:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
18
- <script type="application/ld+json">{"@context":"https://schema.org","@type":"WebApplication","name":"Kenwren","url":"https://cybermaxtools.com/games/kenwren/","image":"https://cybermaxtools.com/games/kenwren/og.png","description":"Make a free 'how well do you know me' quiz: answer 10 questions about yourself, share the link, see who knows you best.","applicationCategory":"GameApplication","operatingSystem":"Any","publisher":{"@type":"Organization","name":"CyberMax","url":"https://cybermaxtools.com/"},"offers":[{"@type":"Offer","price":"0","priceCurrency":"USD","description":"Free"},{"@type":"Offer","price":"2.99","priceCurrency":"USD","description":"CyberMax Fun Pass, monthly"}]}</script>
 
 
 
19
  </head><body>
20
  <div class="wrap">
21
  <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>
22
- <nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="mine-btn" type="button" aria-label="My quizzes">📋</button><button class="icon" id="theme" type="button" aria-label="Switch light or dark">◐</button></header>
23
  <main>
24
  <section class="stage" id="stage" aria-live="polite">
25
  <div class="screen" id="s-home">
26
  <p class="kick">Free · no sign-up <span class="pro-badge">PASS</span></p>
27
- <h1>How well do your friends <em>really</em> know you?</h1>
28
  <p class="lede">Answer 10 fun questions about yourself. Share the link. Friends guess your answers and land on your private scoreboard.</p>
29
  <div class="row"><button class="btn big" id="start" type="button">Make my quiz</button><button class="btn ghost" id="install" type="button" hidden>📲 Add to Home Screen</button></div>
30
  <ol class="how"><li><b>1 minute</b> to make it</li><li><b>One link</b> for every friend</li><li><b>Live scoreboard</b> only you can open</li></ol>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  </div>
32
  <div class="screen" id="s-name" hidden>
33
  <h2 id="name-q">What's your name?</h2>
@@ -65,23 +100,33 @@
65
  </div>
66
  <p class="err" id="err" role="alert" hidden></p>
67
  </section>
68
- <div class="nudge" id="nudge"><span>Make it deeper with the <b>Fun Pass</b>: Deep and Spicy question packs, quizzes that last a year, themes. Works in every CyberMax game.</span><button class="btn pass sm" data-pass="nudge" type="button">See the pass</button></div>
69
  <div class="row skins" id="skins" aria-label="Colour theme"></div>
70
 
71
- <section class="seo">
72
- <h2>How Kenwren works</h2>
73
- <p>Pick your answers to 10 light questions (coffee or tea, night owl or early bird, what you'd do with a lottery win). Kenwren gives you one link to send to friends, family or your group chat. Each friend types a nickname and guesses your answers; when they finish they see their score, the right answers and where they rank. You open your private scoreboard any time to see who knows you best.</p>
74
- <h3>Private by design</h3>
75
- <p>No account, no email, no cookies. We store your display name, your answers and each friend's nickname and score, and delete the quiz after 30 days. Friends never see your answers until they have played, because scoring happens on our server.</p>
76
- <h3>Free, with an optional Fun Pass</h3>
77
- <p>Kenwren is free with the Classic pack of 20 questions. The optional <b>CyberMax Fun Pass</b> ($2.99/month, $19/year, or $29.99 for life) adds the Deep and Spicy (clean) packs, keeps your quizzes for a year, unlocks colour themes, and works in every CyberMax game.</p>
78
- <h3>FAQ</h3>
79
- <p><b>How many friends can play?</b> As many as you like; the scoreboard keeps the top 50. <b>Can I make more than one quiz?</b> Yes, each gets its own link. <b>Why not the big quiz sites?</b> Kenwren has no ads between questions, no sign-up and no tracking, and your answers stay hidden until a friend finishes.</p>
80
- <h2>More free games from CyberMax</h2>
81
- <div class="more"><a href="https://cybermaxtools.com/games/pathmoor/"><b>Pathmoor</b><span>Daily path puzzle</span></a><a href="https://cybermaxtools.com/games/whirlane/"><b>Whirlane</b><span>One-tap orbit dodger</span></a><a href="https://cybermaxtools.com/games/townduel/"><b>Townduel</b><span>Which US town costs more?</span></a></div>
 
 
 
 
 
 
 
 
 
 
82
  </section>
83
  </main>
84
  <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>
85
  </div>
86
  <script type="module" src="game.js"></script>
87
- <!-- cybermax-count:start --><script>(function(){try{var u="https://data.cybermaxtools.com/v?p="+encodeURIComponent(location.host+location.pathname)+"&r="+encodeURIComponent((document.referrer||"").split("/")[2]||"")+(function(){var q=new URLSearchParams(location.search),s=q.get("utm_source");return s?"&us="+encodeURIComponent(s)+"&uc="+encodeURIComponent(q.get("utm_campaign")||""):""})();navigator.sendBeacon?navigator.sendBeacon(u):(new Image).src=u}catch(e){}})()</script><!-- cybermax-count:end --></body></html>
 
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>Kenwren: Free Friend Quiz Maker, How Well Do Your Friends Know You?</title>
4
+ <meta name="description" content="Make a free friend quiz with Kenwren: answer 10 questions, send one link, see who knows you best. Or play solo: 40 levels and a daily read. No sign-up.">
5
  <link rel="canonical" href="https://cybermaxtools.com/games/kenwren/">
6
  <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
+ <meta property="og:title" content="Kenwren: Free Friend Quiz Maker">
8
+ <meta property="og:description" content="Make a free friend quiz with Kenwren: answer 10 questions, send one link, see who knows you best. Or play solo: 40 levels and a daily read. No sign-up.">
9
  <meta property="og:url" content="https://cybermaxtools.com/games/kenwren/">
10
  <meta property="og:image" content="https://cybermaxtools.com/games/kenwren/og.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">
11
  <meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="https://cybermaxtools.com/games/kenwren/og.png">
 
15
  <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Kenwren">
16
  <link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
17
  <script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('kenwren:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
18
+ <meta name="twitter:title" content="Kenwren: Free Friend Quiz Maker">
19
+ <meta name="twitter:description" content="Make a free friend quiz with Kenwren: answer 10 questions, send one link, see who knows you best. Or play solo: 40 levels and a daily read. No sign-up.">
20
+ <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>
21
+ <script type="application/ld+json">{"@context": "https://schema.org", "@graph": [{"@type": "VideoGame", "@id": "https://cybermaxtools.com/games/kenwren/#game", "name": "Kenwren", "alternateName": "Friend quiz game online free", "url": "https://cybermaxtools.com/games/kenwren/", "image": "https://cybermaxtools.com/games/kenwren/og.png", "description": "Kenwren is a free friend quiz maker. Answer 10 light questions about yourself, share one link with friends, family or a group chat, and see who knows you best on your private scoreboard. Solo, read a made-up friend's clue card and guess their answers across 40 levels and a new daily read each day. No account, no email.", "genre": ["Quiz", "Social", "Party game"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-02", "keywords": "Friend quiz game online free, free friend quiz maker, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/kenwren/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/kenwren/#faq", "mainEntity": [{"@type": "Question", "name": "Is Kenwren free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. The Classic quiz pack, solo levels 1-20 and the daily read are free."}}, {"@type": "Question", "name": "Do friends need an account?", "acceptedAnswer": {"@type": "Answer", "text": "No, they just open the link and type a nickname."}}, {"@type": "Question", "name": "Can friends see my answers?", "acceptedAnswer": {"@type": "Answer", "text": "Not until they finish; scoring happens on our server."}}, {"@type": "Question", "name": "How long are quizzes kept?", "acceptedAnswer": {"@type": "Answer", "text": "30 days free, or a year with the Fun Pass."}}, {"@type": "Question", "name": "Does it work in WhatsApp and Instagram?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, the link opens in any browser."}}]}, {"@type": "VideoObject", "name": "Kenwren demo: How well do your friends really know you?", "description": "A 15-second screen recording of the live Kenwren page: Make a quiz about yourself; Answer 10 quick questions; Send the link. Friends guess, you see the scores. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/kenwren/poster.jpg"], "uploadDate": "2026-10-02T18:08:58+00:00", "duration": "PT15S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/kenwren/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/kenwren/", "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/kenwren/#video"}]}</script>
22
  </head><body>
23
  <div class="wrap">
24
  <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>
25
+ <nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="home-btn" type="button" aria-label="Kenwren home">🏠</button><button class="icon" id="mine-btn" type="button" aria-label="My quizzes">📋</button><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>
26
  <main>
27
  <section class="stage" id="stage" aria-live="polite">
28
  <div class="screen" id="s-home">
29
  <p class="kick">Free · no sign-up <span class="pro-badge">PASS</span></p>
30
+ <h1>How well do your friends <em>really</em> know you? <small class="h1kw">Free friend quiz maker</small></h1>
31
  <p class="lede">Answer 10 fun questions about yourself. Share the link. Friends guess your answers and land on your private scoreboard.</p>
32
  <div class="row"><button class="btn big" id="start" type="button">Make my quiz</button><button class="btn ghost" id="install" type="button" hidden>📲 Add to Home Screen</button></div>
33
  <ol class="how"><li><b>1 minute</b> to make it</li><li><b>One link</b> for every friend</li><li><b>Live scoreboard</b> only you can open</li></ol>
34
+ <div class="solo-strip">
35
+ <p class="kick">Or play solo now</p>
36
+ <div class="row"><button class="btn" id="daily-btn" type="button"><span>🗓 Daily read #<span id="daily-n"></span><span id="daily-done" hidden> ✓</span></span></button><button class="btn ghost" id="levels-btn" type="button">▶ 40 levels <span id="levels-stars" class="small"></span></button><span class="streak" id="streak" hidden>🔥 <b>0</b></span></div>
37
+ <p class="muted small">Read a made-up friend's clue card and guess their answers. A new daily read for everyone each day. Levels 1–20 free; 21–40 come with the Fun Pass.</p>
38
+ </div>
39
+ </div>
40
+ <div class="screen" id="s-map" hidden>
41
+ <div class="hud"><button class="btn ghost sm" id="map-home" type="button">← Home</button><b>Solo levels</b><span id="map-stars"></span></div>
42
+ <p class="goal">Goal: read each friend's clue card and guess their answers. Clear a level to open the next.</p>
43
+ <div id="map"></div>
44
+ </div>
45
+ <div class="screen" id="s-solo" hidden>
46
+ <div class="hud"><button class="btn ghost sm" id="so-map" type="button" aria-label="Level map">🗺</button><b id="so-title"></b><button class="btn sm" id="so-retry" type="button" aria-label="Restart (R)">↻</button></div>
47
+ <p class="goal" id="so-goal"></p>
48
+ <div class="pips" id="so-pips"></div>
49
+ <div class="clue-card" id="so-card"><p class="who"><span id="so-emoji"></span> <b id="so-who"></b></p><ul id="so-clues"></ul><p class="hid">🙈</p><div class="peek" id="so-peek" hidden><i></i></div><button class="btn sm" id="so-ready" type="button" hidden>Ready ▶</button></div>
50
+ <div id="so-q-wrap">
51
+ <div class="tbar" id="so-timer" hidden><i></i></div>
52
+ <h2 id="so-q"></h2>
53
+ <div class="opts" id="so-opts"></div>
54
+ </div>
55
+ <div class="hand" id="so-hand" aria-hidden="true" hidden>👆</div>
56
+ </div>
57
+ <div class="screen" id="s-done" hidden>
58
+ <p class="kick" id="dn-kick"></p>
59
+ <h2 id="dn-title"></h2>
60
+ <div class="stars" id="dn-stars"></div>
61
+ <p class="score" id="dn-score"></p>
62
+ <p class="grid" id="dn-grid"></p>
63
+ <p class="muted" id="dn-note"></p>
64
+ <div class="row"><button class="btn big" id="dn-next" type="button">Next ▶</button><button class="btn" id="dn-retry" type="button">↻ Retry</button><button class="btn ghost" id="dn-share" type="button">Share</button><button class="btn ghost" id="dn-map" type="button">🗺 Map</button></div>
65
+ <div id="dn-after"></div>
66
  </div>
67
  <div class="screen" id="s-name" hidden>
68
  <h2 id="name-q">What's your name?</h2>
 
100
  </div>
101
  <p class="err" id="err" role="alert" hidden></p>
102
  </section>
103
+ <div class="nudge" id="nudge"><span>Get more with the <b>Fun Pass</b>: solo levels 21–40, Deep and Spicy question packs, quizzes that last a year, themes. Works in every CyberMax game.</span><button class="btn pass sm" data-pass="nudge" type="button">See the pass</button></div>
104
  <div class="row skins" id="skins" aria-label="Colour theme"></div>
105
 
106
+ <section class="seo" id="guide">
107
+ <h2>Kenwren: friend quiz game online free</h2>
108
+ <p>Kenwren is a free friend quiz maker. Answer 10 light questions about yourself, share one link with friends, family or a group chat, and see who knows you best on your private scoreboard. Solo, read a made-up friend&#x27;s clue card and guess their answers across 40 levels and a new daily read each day. No account, no email.</p>
109
+ <h2>How to play Kenwren</h2>
110
+ <ol><li>Pick your answers to 10 questions.</li><li>Get one link to share.</li><li>Friends type a nickname and guess your answers.</li><li>They see their score and rank when done.</li><li>Open your scoreboard any time.</li></ol>
111
+ <h3>Controls</h3>
112
+ <p><b>Phone and tablet:</b> Tap your answers and share the link in any chat app.</p>
113
+ <p><b>Desktop, laptop and Chromebook:</b> Click your answers, copy the link and paste it anywhere.</p>
114
+ <h2>Kenwren tips and strategy</h2>
115
+ <ul><li>Mix easy and tricky questions so the scoreboard spreads out.</li><li>Share in a group chat for the most players.</li><li>Swap quizzes with a friend and compare scores.</li><li>In solo levels, match each clue to its question before you answer; from level 21 the card hides, so read it first.</li></ul>
116
+ <h2>Game modes</h2>
117
+ <ul><li>Classic pack (free, 20 questions).</li><li>Solo levels 1-20 and the daily read (free).</li><li>Fun Pass: solo levels 21-40, Deep and Spicy (clean) packs, quizzes kept for a year.</li></ul>
118
+ <p>Free to play with no ads and no sign-up. The optional <a href="https://cybermaxtools.com/games/#pass">CyberMax Fun Pass</a> ($2.99/month, $19/year or $29.99 for life) adds cosmetic extras and bonus modes in every CyberMax game.</p>
119
+ <h2>Kenwren FAQ</h2>
120
+ <h3>Is Kenwren free?</h3><p>Yes. The Classic quiz pack, solo levels 1-20 and the daily read are free.</p>
121
+ <h3>Do friends need an account?</h3><p>No, they just open the link and type a nickname.</p>
122
+ <h3>Can friends see my answers?</h3><p>Not until they finish; scoring happens on our server.</p>
123
+ <h3>How long are quizzes kept?</h3><p>30 days free, or a year with the Fun Pass.</p>
124
+ <h3>Does it work in WhatsApp and Instagram?</h3><p>Yes, the link opens in any browser.</p>
125
+ <h2>Similar free games</h2>
126
+ <div class="more"><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/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/games/pathmoor/"><b>Pathmoor</b><span>Turn stones until every path glows</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
127
  </section>
128
  </main>
129
  <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>
130
  </div>
131
  <script type="module" src="game.js"></script>
132
+ </body></html>
kenwren/juice.js ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Kenwren juice: synthesised WebAudio sounds (no audio files), confetti particles and screen shake.
2
+ // Mute is remembered in localStorage (via the kit's store(), which never throws). Shake respects reduced motion.
3
+ // (c) 2026 CyberMax.
4
+ import { store } from './cmx.js';
5
+
6
+ let ctx = null;
7
+ let muted = !!store('kenwren:mute');
8
+ export const isMuted = () => muted;
9
+ export function setMuted(m) { muted = !!m; store('kenwren:mute', muted ? 1 : 0); }
10
+ const calm = () => { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; } };
11
+
12
+ function ac() {
13
+ if (muted) return null;
14
+ try {
15
+ if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)();
16
+ if (ctx.state === 'suspended') ctx.resume();
17
+ return ctx;
18
+ } catch { return null; }
19
+ }
20
+ function tone(freq, at, dur, type = 'sine', vol = 0.18, slide = 0) {
21
+ const a = ac(); if (!a) return;
22
+ const t = a.currentTime + at;
23
+ const o = a.createOscillator(); const g = a.createGain();
24
+ o.type = type; o.frequency.setValueAtTime(freq, t);
25
+ if (slide) o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t + dur);
26
+ g.gain.setValueAtTime(0.0001, t); g.gain.exponentialRampToValueAtTime(vol, t + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
27
+ o.connect(g); g.connect(a.destination); o.start(t); o.stop(t + dur + 0.02);
28
+ }
29
+ export const sfx = {
30
+ tap() { tone(660, 0, 0.05, 'triangle', 0.08); },
31
+ ok() { tone(784, 0, 0.09, 'triangle'); tone(1175, 0.07, 0.14, 'triangle'); },
32
+ bad() { tone(196, 0, 0.22, 'sawtooth', 0.09, -90); tone(147, 0.05, 0.22, 'square', 0.05, -60); },
33
+ star(k = 0) { tone([523, 659, 784][k] || 784, 0, 0.18, 'triangle', 0.16); tone(([523, 659, 784][k] || 784) * 2, 0.04, 0.16, 'sine', 0.06); },
34
+ win() { [523, 659, 784, 1047].forEach((f, k) => tone(f, k * 0.09, 0.22, 'triangle', 0.15)); },
35
+ lose() { tone(330, 0, 0.18, 'triangle', 0.12, -60); tone(247, 0.16, 0.3, 'triangle', 0.12, -60); },
36
+ };
37
+
38
+ export function shake(el) {
39
+ if (!el || calm()) return;
40
+ el.classList.remove('shake'); void el.offsetWidth; el.classList.add('shake');
41
+ setTimeout(() => el.classList.remove('shake'), 420);
42
+ }
43
+
44
+ let cv = null; let parts = []; let raf = 0;
45
+ const COLORS = ['#FFD166', '#FF6A3D', '#2FD3A0', '#B8FFF0', '#FF7AC6', '#ffffff'];
46
+ /** Burst of particles from an element (or a point). `n` pieces; bad=true gives a small grey puff instead. */
47
+ export function burst(from, n = 26, bad = false) {
48
+ try {
49
+ if (!cv) {
50
+ cv = document.createElement('canvas'); cv.className = 'fx'; cv.setAttribute('aria-hidden', 'true');
51
+ document.body.appendChild(cv);
52
+ }
53
+ const dpr = Math.min(2, window.devicePixelRatio || 1);
54
+ if (cv.width !== innerWidth * dpr) { cv.width = innerWidth * dpr; cv.height = innerHeight * dpr; }
55
+ const r = from && from.getBoundingClientRect ? from.getBoundingClientRect() : { left: innerWidth / 2, top: innerHeight / 3, width: 0, height: 0 };
56
+ const x = r.left + r.width / 2; const y = r.top + r.height / 2;
57
+ const count = calm() ? Math.ceil(n / 3) : n;
58
+ for (let k = 0; k < count; k++) {
59
+ const a = Math.random() * Math.PI * 2; const v = (bad ? 1.5 : 3) + Math.random() * (bad ? 2 : 5);
60
+ parts.push({ x, y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - (bad ? 0.5 : 3), life: 1, s: 4 + Math.random() * 5, c: bad ? '#9aa0ad' : COLORS[k % COLORS.length], rot: Math.random() * 6 });
61
+ }
62
+ if (!raf) raf = requestAnimationFrame(step);
63
+ } catch { /* never break the game */ }
64
+ }
65
+ function step() {
66
+ const g = cv.getContext('2d'); const dpr = cv.width / innerWidth;
67
+ g.setTransform(dpr, 0, 0, dpr, 0, 0); g.clearRect(0, 0, innerWidth, innerHeight);
68
+ parts = parts.filter((p) => p.life > 0);
69
+ for (const p of parts) {
70
+ p.x += p.vx; p.y += p.vy; p.vy += 0.22; p.vx *= 0.985; p.life -= 0.022; p.rot += 0.2;
71
+ g.globalAlpha = Math.max(0, p.life); g.fillStyle = p.c;
72
+ g.save(); g.translate(p.x, p.y); g.rotate(p.rot); g.fillRect(-p.s / 2, -p.s / 4, p.s, p.s / 2); g.restore();
73
+ }
74
+ g.globalAlpha = 1;
75
+ raf = parts.length ? requestAnimationFrame(step) : 0;
76
+ if (!raf) g.clearRect(0, 0, innerWidth, innerHeight);
77
+ }
kenwren/levels.js ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Kenwren solo mode: "Read a friend". Each level is a fictional friend with a clue card; guess how they would answer.
2
+ // Pure logic + data, no DOM (tested by tests/levels.test.mjs). Uses only the Classic questions from core.js, which are
3
+ // opinion/personality questions; the people and clues below are made up, so there are no facts to source.
4
+ // core.js is NOT changed by this file, so the toys Worker copy (kenwren-core.js) stays identical. (c) 2026 CyberMax.
5
+ import { BY_ID } from './core.js';
6
+ import { rng, hash, dayNumber } from './cmx.js';
7
+
8
+ export const FREE_MAX = 20; // levels 1-20 are free for everyone
9
+ export const SEED_MAX = 100000; // ?seed= range for "beat my score" links
10
+ export const DAILY_LEN = 6;
11
+
12
+ /** One clue line per option of every Classic question (same order as core.js opts). Each clue points at one answer. */
13
+ export const CLUES = {
14
+ c01: ['Has a group chat that plans every Saturday.', 'Owns a blanket with a pocket for the TV remote.', 'Collects a fridge magnet from every new town.', 'Spends weekends ticking off a to-do list.'],
15
+ c02: ['Grinds espresso beans before saying a word.', 'Keeps twelve kinds of loose-leaf in a drawer.', 'Latte at 8 AM, chamomile at 8 PM.', 'Hot drinks? Just a glass of water, thanks.'],
16
+ c03: ['Always has a crinkly bag of something salty.', 'Hides a cocoa bar in every coat pocket.', 'Brings an apple and a banana everywhere.', 'Calls cheddar a personality trait.'],
17
+ c04: ['Stares at birds with open jealousy.', 'Wishes they could vanish at every awkward party.', 'Hates commutes; wants to blink straight to the office.', 'Always wants to know what you are thinking.'],
18
+ c05: ['Up at 5 AM, smiling, already jogged.', 'Does their best work at 2 AM.', 'Disappears for a siesta at 3 PM.', 'Yawns at breakfast, lunch and dinner.'],
19
+ c06: ['Packs sunscreen and flip-flops first.', 'Owns hiking boots and a summit selfie.', 'Plans holidays around museums and skylines.', 'Has a 40-song driving playlist ready.'],
20
+ c07: ['Phone gallery: 900 photos of a tabby.', 'Stops to pet every pup on the street.', 'Home has a purring sofa and a barking doorbell.', 'Talks to a goldfish named Bubbles.'],
21
+ c08: ['Starts every party on the dance floor.', 'Ends every party in a two-hour kitchen chat.', 'Knows every late-night takeaway menu by heart.', 'Leaves every party before 11.'],
22
+ c09: ['Gets giddy when the first blossoms appear.', 'Lives for long, hot days.', 'Kicks piles of crunchy leaves on purpose.', 'Counts down to snow and scarves.'],
23
+ c10: ['Says fruit on pizza is not a crime.', 'Grabs the spicy salami slices first.', 'Orders anything with porcini on it.', 'Likes their pizza plain. Very plain.'],
24
+ c11: ['Carries two power banks, just in case.', 'Phone is never full, never empty.', 'Always asking: "Anyone got a charger?"', 'Calls you back from someone else\'s phone.'],
25
+ c12: ['Belts out ballads at every open mic.', 'Joins every flash-mob routine.', 'Always first to give the wedding toast.', 'Would skip any stage for a quiet night in.'],
26
+ c13: ['Makes the team plan and hands out the jobs.', 'Fills a whiteboard with wild ideas.', 'Quietly finishes everyone else\'s slides.', 'Group project? Last seen in week one.'],
27
+ c14: ['Rewatches the funniest films just to laugh again.', 'Watches scary movies with the lights off.', 'Cries at every on-screen wedding.', 'Loves car chases and explosions.'],
28
+ c15: ['Answers texts before you lock your phone.', 'Replies… eventually. Like, next Thursday.', 'Sends four-minute audio messages.', 'Answers everything with 👍 or 🙃.'],
29
+ c16: ['Cooks eggs, toast and beans every morning.', 'Owns six boxes of flakes and hoops.', 'Skips food; one mug of coffee is breakfast.', 'Forgets to eat anything until lunch.'],
30
+ c17: ['Can quote every sitcom line.', 'Remembers every team\'s results from last season.', 'Names any song in two notes.', 'Full of trivia nobody asked for.'],
31
+ c18: ['Bed made, shelves dusted, socks sorted.', 'Messy desk, but knows where everything is.', 'Lost a shoe in their own bedroom. Twice.', 'Tidy on Monday, tornado by Friday.'],
32
+ c19: ['Checks the shower for eight-legged guests.', 'Won\'t go up the glass lookout tower.', 'Hands shake before any presentation.', 'Sleeps with a night light on.'],
33
+ c20: ['Has a world map pinned with 50 places to go.', 'Browses home listings for fun.', 'Has a resignation letter drafted, just in case.', 'Would win and keep it a total secret.'],
34
+ };
35
+ export const CLUE_QS = Object.keys(CLUES);
36
+
37
+ /** Fictional friends (one per level, in order). */
38
+ const CAST = [
39
+ ['Mo', '🦊'], ['Priya', '🐼'], ['Jules', '🐸'], ['Theo', '🐻'], ['Ana', '🦉'], ['Kai', '🐯'], ['Zara', '🦄'], ['Leo', '🦁'], ['Nia', '🐨'], ['Omar', '🐙'],
40
+ ['Ivy', '🐝'], ['Ravi', '🐧'], ['Luna', '🐰'], ['Finn', '🐳'], ['Maya', '🦋'], ['Sol', '🐢'], ['Ezra', '🦝'], ['Rosa', '🐞'], ['Dev', '🐲'], ['Tess', '🦩'],
41
+ ['Ari', '🐺'], ['Bea', '🐮'], ['Cal', '🦔'], ['Dani', '🐹'], ['Eli', '🦈'], ['Fay', '🦚'], ['Gus', '🐗'], ['Hana', '🐱'], ['Ike', '��'], ['Jo', '🐶'],
42
+ ['Kit', '🦦'], ['Lou', '🦥'], ['Mei', '🐿️'], ['Ned', '🦬'], ['Ola', '🦜'], ['Pip', '🐣'], ['Quin', '🦭'], ['Remy', '🐭'], ['Suki', '🦢'], ['Tomi', '🐉'],
43
+ ];
44
+
45
+ /** The four solo packs: each adds one twist; every 10th level is a challenge level. */
46
+ export const SOLO_PACKS = [
47
+ { id: 'first', name: 'First Impressions', from: 1, to: 10, twist: 'Clues stay on screen.' },
48
+ { id: 'chat', name: 'Group Chat', from: 11, to: 20, twist: 'Extra clues about other things mixed in.' },
49
+ { id: 'memory', name: 'Memory Lane', from: 21, to: 30, twist: 'The clue card hides after a few seconds.' },
50
+ { id: 'mind', name: 'Mind Reader', from: 31, to: 40, twist: 'Hidden card and a clock on every question.' },
51
+ ];
52
+
53
+ // Hand-tuned table: qn = questions, order = clue order matches questions ('in') or is shuffled, noise = extra
54
+ // off-topic clues, peek = seconds the card stays visible (0 = always), timer = seconds per question (0 = none).
55
+ const T = [
56
+ /* 1-5 */ [3, 'in', 0, 0, 0], [3, 'in', 0, 0, 0], [3, 'in', 0, 0, 0], [4, 'in', 0, 0, 0], [4, 'in', 0, 0, 0],
57
+ /* 6-9 */ [4, 'mix', 0, 0, 0], [4, 'mix', 0, 0, 0], [5, 'mix', 0, 0, 0], [5, 'mix', 0, 0, 0],
58
+ /* 10 */ [6, 'mix', 0, 0, 0],
59
+ /* 11-15 */ [4, 'mix', 1, 0, 0], [4, 'mix', 2, 0, 0], [5, 'mix', 1, 0, 0], [5, 'mix', 2, 0, 0], [5, 'mix', 2, 0, 0],
60
+ /* 16-19 */ [6, 'mix', 2, 0, 0], [6, 'mix', 3, 0, 0], [6, 'mix', 3, 0, 0], [7, 'mix', 3, 0, 0],
61
+ /* 20 */ [8, 'mix', 4, 0, 0],
62
+ /* 21-25 */ [3, 'in', 0, 10, 0], [4, 'in', 0, 10, 0], [4, 'mix', 0, 10, 0], [5, 'mix', 0, 12, 0], [5, 'mix', 1, 12, 0],
63
+ /* 26-29 */ [5, 'mix', 1, 9, 0], [6, 'mix', 1, 12, 0], [6, 'mix', 2, 11, 0], [6, 'mix', 2, 9, 0],
64
+ /* 30 */ [8, 'mix', 2, 14, 0],
65
+ /* 31-35 */ [4, 'mix', 0, 10, 8], [4, 'mix', 1, 10, 7], [5, 'mix', 1, 11, 7], [5, 'mix', 1, 10, 6], [6, 'mix', 1, 12, 6],
66
+ /* 36-39 */ [6, 'mix', 2, 11, 6], [6, 'mix', 2, 10, 5], [7, 'mix', 2, 12, 5], [7, 'mix', 3, 12, 5],
67
+ /* 40 */ [10, 'mix', 2, 16, 6],
68
+ ];
69
+ export const LEVELS = T.map(([qn, order, noise, peek, timer], k) => {
70
+ const n = k + 1;
71
+ return { n, who: CAST[k][0], emoji: CAST[k][1], qn, order, noise, peek, timer, challenge: n % 10 === 0, pack: SOLO_PACKS.find((p) => n >= p.from && n <= p.to).id };
72
+ });
73
+
74
+ function shuffle(arr, r) {
75
+ const a = arr.slice();
76
+ for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(r() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
77
+ return a;
78
+ }
79
+
80
+ /** Build a round from a seed string: questions (with the friend's answers) and the clue card. Same seed, same round. */
81
+ function build(seedStr, who, emoji, qn, order, noise) {
82
+ const r = rng(hash(seedStr));
83
+ let qs = shuffle(CLUE_QS, r);
84
+ // Never pair the two coffee questions (a "tea" friend whose breakfast is "just coffee" would read as a contradiction).
85
+ const pair = { c02: 'c16', c16: 'c02' };
86
+ qs = qs.filter((q, i) => !(pair[q] && qs.indexOf(pair[q]) < i));
87
+ const items = qs.slice(0, qn).map((q) => ({ q, a: Math.floor(r() * BY_ID[q].opts.length) }));
88
+ const extra = qs.slice(qn, qn + noise).map((q) => ({ q, a: Math.floor(r() * BY_ID[q].opts.length), noise: true }));
89
+ let card = [...items, ...extra].map((x) => ({ text: CLUES[x.q][x.a], q: x.q, noise: !!x.noise }));
90
+ if (order !== 'in') card = shuffle(card, r);
91
+ return { who, emoji, items, card };
92
+ }
93
+
94
+ export function buildLevel(n) {
95
+ const L = LEVELS[n - 1];
96
+ if (!L) return null;
97
+ return { ...L, kind: 'level', ...build(`kenwren-level-${n}`, L.who, L.emoji, L.qn, L.order, L.noise) };
98
+ }
99
+
100
+ /** "Daily read" / challenge round from a numeric seed (the daily uses today's puzzle number). */
101
+ export function buildRead(seed) {
102
+ const r = rng(hash(`kenwren-read-${seed}`));
103
+ const [who, emoji] = CAST[Math.floor(r() * CAST.length)];
104
+ return { n: seed, kind: 'read', who, emoji, qn: DAILY_LEN, order: 'mix', noise: 1, peek: 12, timer: 0, challenge: false, ...build(`kenwren-read-${seed}`, who, emoji, DAILY_LEN, 'mix', 1) };
105
+ }
106
+ export const dailySeed = (iso) => dayNumber(iso);
107
+
108
+ /** Stars: 3 = all right, 2 = at most one miss (on 4+ questions), 1 = at least 60% right, 0 = not cleared. */
109
+ export function starsFor(right, qn) {
110
+ if (!(qn > 0) || !(right >= 0)) return 0;
111
+ const wrong = qn - right;
112
+ if (wrong <= 0) return 3;
113
+ if (wrong === 1 && qn >= 4) return 2;
114
+ return right >= Math.ceil(qn * 0.6) ? 1 : 0;
115
+ }
116
+
117
+ /** Saved progress, sanitised: { stars: { [n]: 1..3 }, reached: highest level ever started }. */
118
+ export function cleanProgress(raw) {
119
+ const out = { stars: {}, reached: 1 };
120
+ if (!raw || typeof raw !== 'object') return out;
121
+ for (const [k, v] of Object.entries(raw.stars || {})) {
122
+ const n = Number(k);
123
+ if (Number.isInteger(n) && n >= 1 && n <= LEVELS.length && Number.isInteger(v) && v >= 1 && v <= 3) out.stars[n] = v;
124
+ }
125
+ if (Number.isInteger(raw.reached) && raw.reached >= 1 && raw.reached <= LEVELS.length) out.reached = raw.reached;
126
+ return out;
127
+ }
128
+
129
+ /** Can level n be played? 'ok' | 'progress' (clear the one before first) | 'pass' (Fun Pass level).
130
+ * Never takes a level away: anything up to the highest level already reached stays open, pass or not. */
131
+ export function access(n, prog, pro) {
132
+ if (!Number.isInteger(n) || n < 1 || n > LEVELS.length) return 'progress';
133
+ if (n <= prog.reached) return 'ok';
134
+ if (n > FREE_MAX && !pro) return 'pass';
135
+ if (n > 1 && !(prog.stars[n - 1] > 0)) return 'progress';
136
+ return 'ok';
137
+ }
138
+
139
+ /** Mark level n as started (call only when access() said 'ok'). */
140
+ export function start(prog, n) { return { stars: { ...prog.stars }, reached: Math.max(prog.reached, n) }; }
141
+
142
+ /** Record a finished level: keeps the best stars ever. Clearing does not open a pass level by itself. */
143
+ export function record(prog, n, right, qn) {
144
+ const s = starsFor(right, qn);
145
+ const stars = { ...prog.stars };
146
+ if (s > (stars[n] || 0)) stars[n] = s;
147
+ return { stars, reached: Math.max(prog.reached, n), got: s };
148
+ }
149
+ export const totalStars = (prog) => Object.values(prog.stars).reduce((a, b) => a + b, 0);
150
+ /** The next level to play: the first not yet cleared that is open, else the last open one. */
151
+ export function nextLevel(prog, pro) {
152
+ for (const L of LEVELS) if (!(prog.stars[L.n] > 0)) return access(L.n, prog, pro) === 'ok' ? L.n : Math.max(1, L.n - 1);
153
+ return LEVELS.length;
154
+ }
155
+
156
+ /** Daily streak: consecutive local days with a daily read finished. */
157
+ export function streak(prev, iso) {
158
+ const d = dayNumber(iso);
159
+ const p = prev && Number.isInteger(prev.day) && Number.isInteger(prev.n) ? prev : null;
160
+ if (p && p.day === d) return p;
161
+ return { day: d, n: p && p.day === d - 1 ? p.n + 1 : 1, best: Math.max(p?.best || 0, p && p.day === d - 1 ? p.n + 1 : 1) };
162
+ }
163
+ export const streakNow = (s, iso) => (s && Number.isInteger(s.day) && s.day >= dayNumber(iso) - 1 ? s.n : 0);
164
+
165
+ const INT = /^\d{1,6}$/;
166
+ /** Parse "beat my score" links: ?lvl=<1..40>&score=<0..qn> or ?seed=<1..100000>&score=<0..6>. Garbage -> null. */
167
+ export function parseLink(search) {
168
+ let p;
169
+ try { p = new URLSearchParams(search); } catch { return null; }
170
+ const num = (k) => (INT.test(p.get(k) || '') ? Number(p.get(k)) : null);
171
+ const lvl = num('lvl'); const seed = num('seed'); const sc = num('score');
172
+ if (p.has('lvl')) {
173
+ if (lvl === null || lvl < 1 || lvl > LEVELS.length) return null;
174
+ const qn = LEVELS[lvl - 1].qn;
175
+ return { lvl, score: sc !== null && sc <= qn ? sc : null };
176
+ }
177
+ if (p.has('seed')) {
178
+ if (seed === null || seed < 1 || seed > SEED_MAX) return null;
179
+ return { seed, score: sc !== null && sc <= DAILY_LEN ? sc : null };
180
+ }
181
+ return null;
182
+ }
183
+ export function linkQuery(o) {
184
+ if (o.lvl) return `?lvl=${o.lvl}${Number.isInteger(o.score) ? `&score=${o.score}` : ''}`;
185
+ return `?seed=${o.seed}${Number.isInteger(o.score) ? `&score=${o.score}` : ''}`;
186
+ }
187
+
188
+ /** Emoji result line for sharing. */
189
+ export const grid = (marks) => marks.map((m) => (m ? '🟩' : '🟥')).join('');
kenwren/solo.js ADDED
@@ -0,0 +1,261 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Kenwren solo screens: level map, "read a friend" rounds, daily read, streak, share and beat-my-score links.
2
+ // Logic lives in levels.js; sounds/particles in juice.js. (c) 2026 CyberMax.
3
+ import { BY_ID } from './core.js';
4
+ import { count, store, toast, share, today } from './cmx.js';
5
+ import * as pass from './funpass.js';
6
+ import { LEVELS, SOLO_PACKS, FREE_MAX, buildLevel, buildRead, dailySeed, starsFor, cleanProgress, access, start, record, totalStars, nextLevel, streak, streakNow, parseLink, linkQuery, grid } from './levels.js';
7
+ import { sfx, shake, burst, isMuted, setMuted } from './juice.js';
8
+
9
+ const GAME = 'kenwren';
10
+ const $ = (id) => document.getElementById(id);
11
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
12
+ let show = () => {};
13
+ let PATH = '/games/kenwren/';
14
+ let prog = cleanProgress(store('kenwren:levels'));
15
+ let R = null; // current round: { round, k, marks, beat, busy, timer }
16
+
17
+ const saveProg = (p) => { prog = cleanProgress(p); store('kenwren:levels', prog); };
18
+ const todayIso = () => today();
19
+ const pro = () => pass.isPro();
20
+
21
+ // ------------------------------------------------------------------ home strip (daily + levels + streak)
22
+ export function renderHome() {
23
+ const iso = todayIso(); const n = dailySeed(iso);
24
+ $('daily-n').textContent = n;
25
+ const st = streakNow(store('kenwren:streak'), iso);
26
+ $('streak').hidden = !st; $('streak').querySelector('b').textContent = st;
27
+ $('streak').title = `${st}-day daily streak`;
28
+ const d = store('kenwren:daily');
29
+ $('daily-done').hidden = !(d && d.day === n);
30
+ $('levels-stars').textContent = `★ ${totalStars(prog)}/${LEVELS.length * 3}`;
31
+ }
32
+
33
+ // ------------------------------------------------------------------ level map
34
+ function renderMap() {
35
+ $('map-stars').textContent = `★ ${totalStars(prog)}/${LEVELS.length * 3}`;
36
+ const host = $('map'); host.innerHTML = '';
37
+ for (const P of SOLO_PACKS) {
38
+ const sec = document.createElement('section'); sec.className = 'pack';
39
+ const paid = P.from > FREE_MAX;
40
+ sec.innerHTML = `<h3>${esc(P.name)} <small>${P.from}–${P.to}${paid ? ' · <span class="passtag">Fun Pass</span>' : ' · free'}</small></h3><p class="muted small">${esc(P.twist)}</p><div class="nodes"></div>`;
41
+ const nodes = sec.querySelector('.nodes');
42
+ for (let n = P.from; n <= P.to; n++) {
43
+ const L = LEVELS[n - 1]; const a = access(n, prog, pro()); const s = prog.stars[n] || 0;
44
+ const b = document.createElement('button');
45
+ b.type = 'button'; b.className = `node ${a}${s ? ' done' : ''}${L.challenge ? ' boss' : ''}`; b.dataset.lvl = n;
46
+ b.setAttribute('aria-label', `Level ${n}${L.challenge ? ' (challenge)' : ''}: ${a === 'ok' ? `${s} of 3 stars` : a === 'pass' ? 'needs the Fun Pass' : 'locked'}`);
47
+ b.innerHTML = `<span class="nn">${n}</span><span class="em">${a === 'ok' ? L.emoji : a === 'pass' ? '🔒' : '·'}</span><span class="st">${'★'.repeat(s)}${'☆'.repeat(3 - s)}</span>`;
48
+ b.onclick = () => openLevel(n, null, b);
49
+ nodes.appendChild(b);
50
+ }
51
+ host.appendChild(sec);
52
+ }
53
+ }
54
+ export function showMap() {
55
+ renderMap(); show('s-map');
56
+ const cur = $('map').querySelector(`[data-lvl="${nextLevel(prog, pro())}"]`);
57
+ if (cur) { cur.classList.add('cur'); }
58
+ }
59
+
60
+ function openLevel(n, beat = null, from = null, viaLink = false) {
61
+ const a = access(n, prog, pro());
62
+ const free = n <= FREE_MAX || pro();
63
+ if (a === 'pass' || (viaLink && !free && a !== 'ok')) { showMap(); pass.open(`level-${n}`); return; }
64
+ if (a === 'progress' && !viaLink) { sfx.bad(); shake(from); toast(`Clear level ${n - 1} first.`); return; }
65
+ saveProg(start(prog, n));
66
+ play(buildLevel(n), beat);
67
+ }
68
+ function openRead(seed, beat = null) { play(buildRead(seed), beat); }
69
+
70
+ // ------------------------------------------------------------------ a round
71
+ function play(round, beat) {
72
+ clearTimers();
73
+ R = { round, k: 0, marks: [], beat, busy: false, peekT: 0, qT: 0 };
74
+ const { who, emoji, qn, kind, n } = round;
75
+ const pack = kind === 'level' ? SOLO_PACKS.find((p) => p.id === round.pack).name : (n === dailySeed(todayIso()) ? 'Daily read' : 'Challenge read');
76
+ $('so-title').textContent = kind === 'level' ? `Level ${n}${round.challenge ? ' ★ challenge' : ''} · ${pack}` : `${pack} #${n}`;
77
+ const need = Math.ceil(qn * 0.6);
78
+ $('so-goal').innerHTML = `Goal: ${round.peek ? 'memorise the card, then guess' : 'guess'} how <b>${esc(who)}</b> answers${round.peek ? '' : ', using the clue card'}. ${need}/${qn} to clear${beat !== null ? ` · <b class="beat">Beat ${beat}</b>` : ''}`;
79
+ $('so-emoji').textContent = emoji; $('so-who').textContent = who;
80
+ $('so-clues').innerHTML = round.card.map((c, k) => `<li data-k="${k}">${esc(c.text)}</li>`).join('');
81
+ $('so-card').classList.remove('hidden');
82
+ $('so-pips').innerHTML = Array.from({ length: qn }, () => '<i></i>').join('');
83
+ count(GAME, 'play', kind === 'level' ? 'level' : 'daily');
84
+ show('s-solo');
85
+ if (round.peek) {
86
+ $('so-q-wrap').hidden = true; $('so-ready').hidden = false; $('so-peek').hidden = false;
87
+ const bar = $('so-peek').querySelector('i');
88
+ bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth;
89
+ bar.style.transition = `width ${round.peek}s linear`; bar.style.width = '0%';
90
+ R.peekT = setTimeout(hideCard, round.peek * 1000);
91
+ } else {
92
+ $('so-ready').hidden = true; $('so-peek').hidden = true; $('so-q-wrap').hidden = false; ask();
93
+ }
94
+ }
95
+ function hideCard() {
96
+ if (!R) return;
97
+ clearTimeout(R.peekT);
98
+ $('so-card').classList.add('hidden'); $('so-ready').hidden = true; $('so-peek').hidden = true; $('so-q-wrap').hidden = false;
99
+ sfx.tap(); ask();
100
+ }
101
+ function clearTimers() { if (R) { clearTimeout(R.peekT); clearTimeout(R.qT); clearTimeout(R.nextT); } $('so-hand').hidden = true; }
102
+
103
+ function ask() {
104
+ const { round, k } = R; const it = round.items[k]; const q = BY_ID[it.q];
105
+ R.busy = false;
106
+ $('so-q').textContent = q.you.replaceAll('{n}', round.who);
107
+ const box = $('so-opts'); box.innerHTML = '';
108
+ q.opts.forEach((o, j) => {
109
+ const b = document.createElement('button');
110
+ b.type = 'button'; b.className = 'opt'; b.textContent = o;
111
+ b.onclick = () => pick(j, b);
112
+ box.appendChild(b);
113
+ });
114
+ // easy levels (clues in question order, always visible): light up the clue that belongs to this question
115
+ $('so-clues').querySelectorAll('li').forEach((li) => li.classList.remove('hint'));
116
+ $('so-clues').querySelectorAll('li').forEach((li) => li.classList.toggle('now', round.order === 'in' && !round.peek && Number(li.dataset.k) === k));
117
+ if (round.kind === 'level' && round.n === 1 && !(prog.stars[1] > 0)) hint(k, it.a);
118
+ if (round.timer) {
119
+ const t = $('so-timer'); const bar = t.querySelector('i'); t.hidden = false;
120
+ bar.style.transition = 'none'; bar.style.width = '100%'; void bar.offsetWidth;
121
+ bar.style.transition = `width ${round.timer}s linear`; bar.style.width = '0%';
122
+ R.qT = setTimeout(() => pick(-1, null), round.timer * 1000);
123
+ } else $('so-timer').hidden = true;
124
+ }
125
+
126
+ // No-text tutorial on level 1: a hand slides from the matching clue to the matching answer.
127
+ function hint(k, a) {
128
+ const hand = $('so-hand'); const stage = $('stage');
129
+ const clue = $('so-clues').querySelector(`li[data-k="${k}"]`); const opt = $('so-opts').children[a];
130
+ if (!clue || !opt) return;
131
+ const sr = stage.getBoundingClientRect();
132
+ const pos = (el) => { const r = el.getBoundingClientRect(); return [r.left - sr.left + Math.min(r.width - 30, 60), r.top - sr.top + r.height / 2]; };
133
+ const [x1, y1] = pos(clue); const [x2, y2] = pos(opt);
134
+ hand.hidden = false; opt.classList.add('hint'); clue.classList.add('hint');
135
+ try {
136
+ hand.getAnimations().forEach((an) => an.cancel());
137
+ hand.animate([{ transform: `translate(${x1}px,${y1}px) scale(1)`, opacity: 0 }, { transform: `translate(${x1}px,${y1}px) scale(1)`, opacity: 1, offset: 0.15 },
138
+ { transform: `translate(${x2}px,${y2}px) scale(1)`, opacity: 1, offset: 0.7 }, { transform: `translate(${x2}px,${y2}px) scale(.85)`, opacity: 1, offset: 0.8 }, { transform: `translate(${x2}px,${y2}px) scale(1)`, opacity: 0 }],
139
+ { duration: 1800, iterations: Infinity, easing: 'ease-in-out' });
140
+ } catch { hand.style.transform = `translate(${x2}px,${y2}px)`; }
141
+ }
142
+
143
+ function pick(j, btn) {
144
+ if (!R || R.busy) return;
145
+ R.busy = true; clearTimeout(R.qT); $('so-hand').hidden = true;
146
+ const { round, k } = R; const it = round.items[k];
147
+ const ok = j === it.a;
148
+ const opts = $('so-opts').children;
149
+ if (navigator.vibrate) try { navigator.vibrate(ok ? 10 : [20, 40, 20]); } catch { /* */ }
150
+ [...opts].forEach((b, m) => { b.disabled = true; if (m === it.a) b.classList.add('right'); });
151
+ if (btn && !ok) btn.classList.add('wrong');
152
+ R.marks.push(ok);
153
+ const pip = $('so-pips').children[k]; pip.className = ok ? 'ok' : 'no';
154
+ if (ok) { sfx.ok(); burst(btn || opts[it.a], 22); } else { sfx.bad(); shake($('stage')); burst(btn || opts[it.a], 10, true); if (j < 0) toast('Time!', 900); }
155
+ R.nextT = setTimeout(() => { if (R.k + 1 < round.qn) { R.k++; ask(); } else done(); }, ok ? 480 : 950);
156
+ }
157
+
158
+ // ------------------------------------------------------------------ result
159
+ function done() {
160
+ const { round, marks, beat } = R; const right = marks.filter(Boolean).length; const qn = round.qn;
161
+ $('so-timer').hidden = true;
162
+ const st = starsFor(right, qn);
163
+ let note = ''; let got = st;
164
+ const iso = todayIso(); const isDaily = round.kind === 'read' && round.n === dailySeed(iso);
165
+ if (round.kind === 'level') {
166
+ const before = prog.stars[round.n] || 0;
167
+ const r = record(prog, round.n, right, qn); got = r.got; saveProg(r);
168
+ if (got) count(GAME, 'win', `level-${round.n}`);
169
+ note = got ? (got > before && before ? 'New best!' : '') : `You need ${Math.ceil(qn * 0.6)} right to clear it. One tap to try again.`;
170
+ } else {
171
+ if (isDaily) {
172
+ const d = store('kenwren:daily');
173
+ if (!(d && d.day === round.n)) { store('kenwren:daily', { day: round.n, right, marks }); store('kenwren:streak', streak(store('kenwren:streak'), iso)); count(GAME, 'win', 'daily'); }
174
+ const s = streakNow(store('kenwren:streak'), iso);
175
+ note = `🔥 ${s}-day streak. A new friend to read tomorrow.`;
176
+ } else note = 'Challenge read: same friend, same clues for everyone with this link.';
177
+ }
178
+ if (beat !== null) note = `${right > beat ? `You beat ${beat}! 🎉` : right === beat ? `Tied at ${beat}. So close.` : `${beat} still wins. Try again?`} ${note}`;
179
+ $('dn-kick').textContent = round.kind === 'level' ? `Level ${round.n}${round.challenge ? ' · challenge' : ''}` : (isDaily ? `Daily read #${round.n}` : `Challenge read #${round.n}`);
180
+ $('dn-title').textContent = got || round.kind !== 'level' ? `You read ${round.who} ${right === qn ? 'perfectly' : 'well'}!` : `${round.who} stumped you`;
181
+ if (round.kind !== 'level' && right < Math.ceil(qn * 0.6)) $('dn-title').textContent = `${round.who} stumped you`;
182
+ $('dn-score').textContent = `${right}/${qn}`;
183
+ $('dn-grid').textContent = grid(marks);
184
+ $('dn-note').textContent = note;
185
+ const stars = $('dn-stars');
186
+ stars.innerHTML = round.kind === 'level' ? [0, 1, 2].map((k) => `<span class="${k < got ? 'on' : ''}">★</span>`).join('') : '';
187
+ // next button
188
+ const nb = $('dn-next');
189
+ if (round.kind === 'level') {
190
+ const nx = round.n + 1;
191
+ if (!got) { nb.textContent = '↻ Try again'; nb.onclick = () => openLevel(round.n, beat); }
192
+ else if (nx > LEVELS.length) { nb.textContent = '🗺 All levels'; nb.onclick = showMap; }
193
+ else if (access(nx, prog, pro()) === 'pass') { nb.textContent = `Level ${nx} 🔒 Fun Pass`; nb.onclick = () => pass.open(`level-${nx}`); }
194
+ else { nb.textContent = `Level ${nx} ▶`; nb.onclick = () => openLevel(nx); }
195
+ } else { nb.textContent = '▶ Solo levels'; nb.onclick = () => openLevel(nextLevel(prog, pro())); }
196
+ $('dn-retry').onclick = () => (round.kind === 'level' ? openLevel(round.n, beat) : openRead(round.n, beat));
197
+ $('dn-share').onclick = () => shareRound(round, right, marks);
198
+ show('s-done');
199
+ if (got || (round.kind !== 'level' && right >= Math.ceil(qn * 0.6))) {
200
+ sfx.win(); burst($('dn-score'), 60);
201
+ [...stars.children].forEach((s, k) => { if (k < got) setTimeout(() => { s.classList.add('pop'); sfx.star(k); }, 250 + k * 220); });
202
+ } else { sfx.lose(); shake($('stage')); }
203
+ if (isDaily) pass.afterWin($('dn-after'), 'daily'); else $('dn-after').innerHTML = '';
204
+ renderHome();
205
+ }
206
+
207
+ export function shareText(round, right, marks, streakN = 0) {
208
+ if (round.kind === 'level') {
209
+ const s = starsFor(right, round.qn);
210
+ return `Kenwren level ${round.n} ${'⭐'.repeat(s) || '💥'} ${right}/${round.qn}\n${grid(marks)}\nCan you read ${round.who} better? Beat ${right}:`;
211
+ }
212
+ return `Kenwren Daily read #${round.n} ${right}/${round.qn}${streakN ? ` 🔥${streakN}` : ''}\n${grid(marks)}\nHow well can you read a stranger? Beat ${right}:`;
213
+ }
214
+ function shareRound(round, right, marks) {
215
+ const q = linkQuery(round.kind === 'level' ? { lvl: round.n, score: right } : { seed: round.n, score: right });
216
+ share(GAME, shareText(round, right, marks, streakNow(store('kenwren:streak'), todayIso())), `${PATH}${q}`);
217
+ }
218
+
219
+ // ------------------------------------------------------------------ wiring
220
+ export function initSolo(opts) {
221
+ show = opts.show; PATH = opts.path || PATH;
222
+ $('daily-btn').onclick = () => {
223
+ const n = dailySeed(todayIso()); const d = store('kenwren:daily');
224
+ if (d && d.day === n && Array.isArray(d.marks)) {
225
+ // already played today: show today's result (first try counts), with share + practice
226
+ R = { round: buildRead(n), marks: d.marks.map(Boolean), beat: null }; R.k = R.marks.length; done();
227
+ toast('You played today. Replays are practice; your first score stands.', 2600);
228
+ return;
229
+ }
230
+ openRead(n);
231
+ };
232
+ $('levels-btn').onclick = () => { sfx.tap(); showMap(); };
233
+ $('so-map').onclick = () => { clearTimers(); R = null; showMap(); };
234
+ $('dn-map').onclick = showMap;
235
+ $('map-home').onclick = () => { renderHome(); show('s-home'); };
236
+ $('so-retry').onclick = () => { if (!R) return; const r = R.round; r.kind === 'level' ? openLevel(r.n, R.beat) : openRead(r.n, R.beat); };
237
+ $('so-ready').onclick = hideCard;
238
+ const mute = $('mute');
239
+ const paint = () => { mute.textContent = isMuted() ? '🔇' : '🔊'; mute.setAttribute('aria-pressed', String(isMuted())); };
240
+ mute.onclick = () => { setMuted(!isMuted()); paint(); if (!isMuted()) sfx.ok(); };
241
+ paint();
242
+ document.addEventListener('keydown', (e) => {
243
+ if ($('s-solo').hidden) return;
244
+ if (/^[1-4]$/.test(e.key) && !$('so-q-wrap').hidden && R && !R.busy) { const b = $('so-opts').children[Number(e.key) - 1]; if (b) pick(Number(e.key) - 1, b); }
245
+ else if (e.key === 'r' || e.key === 'R') $('so-retry').click();
246
+ else if ((e.key === ' ' || e.key === 'Enter') && !$('so-ready').hidden) { e.preventDefault(); hideCard(); }
247
+ });
248
+ pass.onChange(() => { if (!$('s-map').hidden) renderMap(); });
249
+ renderHome();
250
+ }
251
+
252
+ /** Handle ?lvl= / ?seed= beat-my-score links. Returns true when it opened something. */
253
+ export function route(search) {
254
+ const L = parseLink(search);
255
+ if (!L) return false;
256
+ if (L.lvl) openLevel(L.lvl, L.score, null, true); else openRead(L.seed, L.score);
257
+ return true;
258
+ }
259
+
260
+ // test/screenshot hook
261
+ window.__kenwrenSolo = { get prog() { return prog; }, get round() { return R && R.round; }, answer: () => R && R.round.items[R.k].a };
kenwren/style.css CHANGED
@@ -33,3 +33,45 @@
33
  .skins button[aria-pressed=true]{border-color:var(--ink)}.skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
34
  .stage details{margin-top:14px}
35
  #packs{flex-wrap:wrap!important;overflow:visible!important;margin:14px 0 0!important;padding:0!important}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  .skins button[aria-pressed=true]{border-color:var(--ink)}.skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
34
  .stage details{margin-top:14px}
35
  #packs{flex-wrap:wrap!important;overflow:visible!important;margin:14px 0 0!important;padding:0!important}
36
+ /* solo mode: map, clue card, pips, stars, juice */
37
+ .stage{position:relative}
38
+ .solo-strip{margin-top:22px;padding-top:16px;border-top:1px solid rgba(255,255,255,.18)}.solo-strip .kick{margin-bottom:8px}.solo-strip p.small{margin:8px 0 0}
39
+ .streak{font-weight:900;font-size:18px;background:rgba(255,255,255,.12);border-radius:999px;padding:6px 12px}
40
+ .hud{display:flex;align-items:center;gap:8px;justify-content:space-between;margin-bottom:6px}.hud b{flex:1;text-align:center;font-size:15px}
41
+ .hud .btn.sm{min-width:44px}
42
+ .goal{margin:0 0 10px;font-size:15px;background:rgba(0,0,0,.18);border-radius:10px;padding:7px 10px}.goal .beat{color:var(--k-hi)}
43
+ :root[data-skin=paper] .goal{background:rgba(0,0,0,.06)}
44
+ .pips{display:flex;gap:6px;margin:0 0 10px}.pips i{flex:1;height:8px;border-radius:9px;background:rgba(255,255,255,.2)}.pips i.ok{background:#2FD3A0}.pips i.no{background:#FF5C5C}
45
+ .clue-card{background:rgba(255,255,255,.1);border:2px solid rgba(255,255,255,.22);border-radius:16px;padding:10px 14px;margin-bottom:10px}
46
+ :root[data-skin=paper] .clue-card{background:#fff;border-color:rgba(0,0,0,.12)}
47
+ .clue-card .who{margin:0 0 4px;font-size:18px}.clue-card #so-emoji{font-size:26px}
48
+ .clue-card ul{margin:0;padding-left:20px}.clue-card li{padding:2px 0;font-size:15px;transition:background .2s}
49
+ .clue-card li.now,.clue-card li.hint{background:rgba(255,209,102,.22);border-radius:6px;outline:2px solid var(--k-hi);outline-offset:1px}
50
+ .clue-card .hid{display:none;margin:0;font-size:28px;text-align:center}
51
+ .clue-card.hidden ul{display:none}.clue-card.hidden .hid{display:block}
52
+ .peek,.tbar{height:6px;border-radius:9px;background:rgba(255,255,255,.2);overflow:hidden;margin:8px 0}.peek i,.tbar i{display:block;height:100%;width:100%;background:var(--k-hi)}
53
+ .tbar i{background:#FF8A5C}
54
+ #so-ready{margin-top:6px}
55
+ #s-solo h2{margin:4px 0 10px;font-size:clamp(19px,4.6vw,24px)}
56
+ .opt.right{background:#2FD3A0!important;color:#11141B!important;border-color:#2FD3A0!important}.opt.wrong{background:#FF5C5C!important;color:#fff!important;border-color:#FF5C5C!important}
57
+ .opt.hint{border-color:var(--k-hi);box-shadow:0 0 0 3px rgba(255,209,102,.4)}
58
+ .opt:disabled{cursor:default}
59
+ .hand{position:absolute;left:0;top:0;font-size:38px;pointer-events:none;z-index:5;margin:-6px 0 0 -12px;filter:drop-shadow(0 3px 4px rgba(0,0,0,.4))}
60
+ .stars{font-size:52px;letter-spacing:6px;line-height:1;margin:4px 0}.stars span{opacity:.25;display:inline-block}.stars span.on{opacity:.25}.stars span.pop{opacity:1;color:var(--k-hi);animation:pop .35s ease-out}
61
+ @keyframes pop{0%{transform:scale(.3)}70%{transform:scale(1.25)}100%{transform:scale(1)}}
62
+ .grid{font-size:22px;letter-spacing:2px;margin:6px 0}
63
+ #s-done .row{margin-top:10px}
64
+ .pack h3{margin:14px 0 2px;font-size:17px}.pack h3 small{font-weight:600;opacity:.75}.passtag{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:1px 8px;font-size:12px;font-weight:800}
65
+ .pack p.small{margin:0 0 8px}
66
+ .nodes{display:grid;grid-template-columns:repeat(5,1fr);gap:8px}
67
+ .node{display:flex;flex-direction:column;align-items:center;gap:0;padding:6px 2px;border-radius:14px;border:2px solid rgba(255,255,255,.25);background:rgba(255,255,255,.08);color:var(--k-ink);font:inherit;cursor:pointer;min-height:70px;touch-action:manipulation}
68
+ :root[data-skin=paper] .node{background:#fff;border-color:rgba(0,0,0,.12)}
69
+ .node .nn{font-weight:900;font-size:14px}.node .em{font-size:22px;line-height:1.1}.node .st{font-size:11px;color:var(--k-hi);letter-spacing:1px}
70
+ .node.done{border-color:var(--k-hi)}.node.boss{border-style:dashed}.node.progress{opacity:.45}.node.pass{opacity:.8}
71
+ .node.cur{box-shadow:0 0 0 3px var(--k-hi);animation:pulse 1.4s ease-in-out infinite}
72
+ @keyframes pulse{50%{box-shadow:0 0 0 6px rgba(255,209,102,.35)}}
73
+ .shake{animation:shake .38s}
74
+ @keyframes shake{20%{transform:translateX(-8px)}40%{transform:translateX(7px)}60%{transform:translateX(-5px)}80%{transform:translateX(3px)}}
75
+ canvas.fx{position:fixed;inset:0;width:100vw;height:100vh;pointer-events:none;z-index:98}
76
+ @media (max-width:420px){.nodes{gap:6px}.node{min-height:64px}.stars{font-size:44px}}
77
+ @media (max-width:420px){.top nav{display:none}.top .icon{padding:8px 7px}}
kenwren/sw.js CHANGED
@@ -1,8 +1,8 @@
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 = 'kenwren';
4
- const CACHE = `cmx-${GAME}-8953bc3c85`;
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", "manifest.webmanifest", "style.css"];
6
 
7
  self.addEventListener('install', (e) => {
8
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
 
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 = 'kenwren';
4
+ const CACHE = `cmx-${GAME}-f18f07a39a`;
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", "juice.js", "levels.js", "manifest.webmanifest", "solo.js", "style.css"];
6
 
7
  self.addEventListener('install', (e) => {
8
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));