CyberMax-tools commited on
Commit
7154ad5
·
verified ·
1 Parent(s): 0f71cc7

Deploy brimbloom

Browse files
Files changed (9) hide show
  1. README.md +18 -5
  2. cmx.js +3 -1
  3. core.js +121 -0
  4. funpass.js +2 -2
  5. game.js +286 -85
  6. index.html +39 -20
  7. levels.js +341 -0
  8. style.css +5 -0
  9. sw.js +2 -2
README.md CHANGED
@@ -1,24 +1,37 @@
1
  ---
2
  title: "Brimbloom: One-tap timing game"
3
- emoji: 🎮
4
- colorFrom: gray
5
- colorTo: blue
6
  sdk: static
7
  pinned: false
8
  license: other
 
9
  short_description: "One-tap timing: stop the bloom on the brim"
10
  tags:
11
  - game
 
 
 
12
  - html5
13
  - browser-game
14
  - pwa
15
  - cybermax
16
- thumbnail: https://huggingface.co/spaces/CyberMax-tools/brimbloom/resolve/main/thumbnail.png
17
  ---
18
  **Brimbloom**: One-tap timing: stop the bloom on the brim. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
19
 
20
  Play it full screen: https://cybermaxtools.com/games/brimbloom/?utm_source=huggingface
21
 
22
- The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $4.99 once) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermaxtools.com/games/?utm_source=huggingface
 
 
 
 
 
 
 
 
 
 
23
 
24
  More free games: https://cybermaxtools.com/games/ · © 2026 CyberMax. All rights reserved.
 
1
  ---
2
  title: "Brimbloom: One-tap timing game"
3
+ emoji: 🌸
4
+ colorFrom: pink
5
+ colorTo: yellow
6
  sdk: static
7
  pinned: false
8
  license: other
9
+ thumbnail: https://cybermaxtools.com/games/brimbloom/og.png
10
  short_description: "One-tap timing: stop the bloom on the brim"
11
  tags:
12
  - game
13
+ - arcade
14
+ - one-tap
15
+ - timing
16
  - html5
17
  - browser-game
18
  - pwa
19
  - cybermax
 
20
  ---
21
  **Brimbloom**: One-tap timing: stop the bloom on the brim. A free daily game from CyberMax: the same challenge for everyone each day, a shareable result, works offline and installs on your phone (no app store, no sign-up).
22
 
23
  Play it full screen: https://cybermaxtools.com/games/brimbloom/?utm_source=huggingface
24
 
25
+ **Watch the real gameplay demo:** https://cybermaxtools.com/media/demos/brimbloom/demo-16x9.mp4
26
+
27
+ ![Brimbloom gameplay](https://cybermaxtools.com/media/demos/brimbloom/poster.jpg)
28
+
29
+ **Embed it free on your site** (works on phones, keeps a small link to CyberMax):
30
+
31
+ ```html
32
+ <iframe src="https://cybermaxtools.com/embed/brimbloom/" width="480" height="720" style="border:0;max-width:100%;aspect-ratio:2/3;height:auto" title="Brimbloom: free game by CyberMax" loading="lazy" allowfullscreen></iframe>
33
+ ```
34
+
35
+ The optional **CyberMax Fun Pass** ($2.99/month, $19/year or $29.99 for life) adds extra packs, the full archive of past dailies and colour themes in every CyberMax game: https://cybermaxtools.com/games/?utm_source=huggingface
36
 
37
  More free games: https://cybermaxtools.com/games/ · © 2026 CyberMax. All rights reserved.
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
  }
core.js CHANGED
@@ -79,3 +79,124 @@ export function grid(marks) {
79
  for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
80
  return rows.join('\n');
81
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
80
  return rows.join('\n');
81
  }
82
+
83
+ // ------------------------------------------------------------------ 30 levels (own seed space: the daily is untouched)
84
+ // Each level is a finite goal: bloom `goal` flowers before you run out of `lives` (a miss costs a life and the next
85
+ // flower comes). Speeds s and widths w are [min, max] ranges; pulse = surge amplitude range, breathe = ring drift,
86
+ // two = chance a flower has two brims (stop it twice), fade = the ring fades as the bloom nears it, decoy = a false
87
+ // ring in a warning colour. Bosses (every 10th) give one life. (c) 2026 CyberMax.
88
+ export const HOLD = 14; // ticks a two-brim flower rests after its first stop
89
+ const L = (name, goal, s, w, o = {}) => ({ name, goal, s, w, lives: o.lives ?? 3, ...o });
90
+ export const LEVELS = [
91
+ L('First bud', 5, [0.36, 0.4], [0.2, 0.22]),
92
+ L('Petal pond', 6, [0.4, 0.46], [0.18, 0.2]),
93
+ L('Quickening', 7, [0.45, 0.52], [0.16, 0.18]),
94
+ L('Narrow brim', 8, [0.48, 0.56], [0.14, 0.16]),
95
+ L('Surge', 8, [0.46, 0.52], [0.16, 0.18], { pulse: [0.2, 0.35], twist: 'Surging blooms rush, then ease' }),
96
+ L('Rush hour', 8, [0.5, 0.58], [0.15, 0.17], { pulse: [0.25, 0.4] }),
97
+ L('Tide pool', 9, [0.54, 0.62], [0.14, 0.16], { pulse: [0.25, 0.45] }),
98
+ L('Quick petals', 10, [0.58, 0.66], [0.13, 0.15], { pulse: [0.3, 0.5], lives: 2 }),
99
+ L('Thin water', 10, [0.6, 0.68], [0.12, 0.14], { pulse: [0.3, 0.5], lives: 2 }),
100
+ L('Garden gauntlet', 12, [0.6, 0.72], [0.12, 0.14], { pulse: [0.2, 0.5], lives: 1, boss: true }),
101
+ L('Breathing ring', 8, [0.5, 0.56], [0.15, 0.17], { breathe: [0.02, 0.03], twist: 'Breathing rings drift in and out' }),
102
+ L('Low tide', 9, [0.54, 0.6], [0.14, 0.16], { breathe: [0.02, 0.035] }),
103
+ L('Swell', 9, [0.54, 0.62], [0.14, 0.16], { breathe: [0.02, 0.035], pulse: [0.2, 0.35] }),
104
+ L('Ripples', 10, [0.58, 0.66], [0.13, 0.15], { breathe: [0.025, 0.04], pulse: [0.2, 0.4], lives: 2 }),
105
+ L('Moon pond', 10, [0.62, 0.7], [0.12, 0.14], { breathe: [0.025, 0.04], pulse: [0.25, 0.45], lives: 2 }),
106
+ L('Two brims', 6, [0.46, 0.52], [0.16, 0.18], { two: 1, twist: 'Two brims: stop each flower twice' }),
107
+ L('Double take', 7, [0.5, 0.56], [0.15, 0.17], { two: 0.7 }),
108
+ L('Second wind', 8, [0.52, 0.6], [0.14, 0.16], { two: 0.6, pulse: [0.2, 0.35] }),
109
+ L('Echo bloom', 8, [0.56, 0.62], [0.13, 0.15], { two: 0.6, breathe: [0.02, 0.03], lives: 2 }),
110
+ L('Twin gauntlet', 10, [0.58, 0.66], [0.12, 0.14], { two: 0.6, pulse: [0.2, 0.4], breathe: [0.02, 0.03], lives: 1, boss: true }),
111
+ L('Fading brim', 8, [0.5, 0.56], [0.15, 0.17], { fade: true, twist: 'Fading rings vanish as the bloom nears' }),
112
+ L('Mist', 9, [0.54, 0.6], [0.14, 0.16], { fade: true, pulse: [0.2, 0.35] }),
113
+ L('Ghost pond', 9, [0.56, 0.62], [0.14, 0.16], { fade: true, breathe: [0.02, 0.03] }),
114
+ L('Fog bank', 10, [0.58, 0.66], [0.13, 0.15], { fade: true, two: 0.4, lives: 2 }),
115
+ L('Night bloom', 10, [0.6, 0.68], [0.12, 0.14], { fade: true, pulse: [0.25, 0.45], lives: 2 }),
116
+ L('Thorn ring', 8, [0.52, 0.58], [0.14, 0.16], { decoy: true, twist: 'Thorn rings: only the bright ring counts' }),
117
+ L('Bramble', 9, [0.56, 0.62], [0.13, 0.15], { decoy: true, pulse: [0.2, 0.4] }),
118
+ L('Briar patch', 10, [0.58, 0.66], [0.13, 0.15], { decoy: true, breathe: [0.02, 0.03], two: 0.3, lives: 2 }),
119
+ L('Thornfall', 10, [0.62, 0.7], [0.12, 0.14], { decoy: true, fade: true, pulse: [0.2, 0.4], lives: 2 }),
120
+ L('Full bloom', 14, [0.6, 0.72], [0.11, 0.13], { pulse: [0.2, 0.45], breathe: [0.02, 0.03], two: 0.4, fade: true, decoy: true, lives: 1, boss: true }),
121
+ ];
122
+
123
+ const pick = (r, [a, b]) => a + r() * (b - a);
124
+
125
+ /** Flower i of level num (1-based): same shape as bloom() plus rings (one or two brims), fade and decoy. */
126
+ export function levelBloom(num, i) {
127
+ const D = LEVELS[num - 1], r = rng(`brimbloom:lv:${num}:${i}`);
128
+ const easeIn = Math.min(1, i / Math.max(1, D.goal - 1)); // speed climbs across the level
129
+ const s = D.s[0] + (D.s[1] - D.s[0]) * (0.6 * easeIn + 0.4 * r());
130
+ const w = pick(r, D.w);
131
+ const p = D.pulse && i > 0 ? pick(r, D.pulse) : 0;
132
+ const b = D.breathe && i > 0 ? pick(r, D.breathe) : 0;
133
+ const two = D.two && r() < D.two && (D.two >= 1 || i > 0);
134
+ let rings;
135
+ if (two) {
136
+ const c1 = 0.34 + r() * 0.1;
137
+ rings = [{ c: c1, w }, { c: Math.min(0.88, c1 + 0.34 + r() * 0.08), w }];
138
+ } else rings = [{ c: 0.5 + r() * 0.36, w }];
139
+ const out = { c: rings[0].c, w, s, p, b, n: 5 + Math.floor(r() * 4), hue: Math.floor(r() * 360), rings, k: 0, fade: !!D.fade };
140
+ if (D.decoy) {
141
+ // a thorn ring well clear of every real brim (inside the first brim when there is room, else outside)
142
+ const c0 = rings[0].c, last = rings[rings.length - 1].c;
143
+ const dc = c0 - 0.28 >= 0.2 ? c0 - 0.24 - r() * 0.04 : last + 0.24;
144
+ if (dc <= 0.98) out.decoy = { c: dc, w };
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /** Stops a level asks for (two-brim flowers count twice). */
150
+ export function levelStops(num) {
151
+ let n = 0;
152
+ for (let i = 0; i < LEVELS[num - 1].goal; i++) n += levelBloom(num, i).rings.length;
153
+ return n;
154
+ }
155
+ /** Level score: 2 per perfect stop, 1 per good stop. Star thresholds [1 clear, 2, 3] in score points. */
156
+ export function levelThresholds(num) {
157
+ const max = 2 * levelStops(num);
158
+ return [1, Math.ceil(max * 0.7), Math.ceil(max * 0.86)];
159
+ }
160
+ /** Stars for a finished level: 0 if failed, else 1-3 by score. */
161
+ export function levelStars(num, score, won = true) {
162
+ if (!won) return 0;
163
+ const t = levelThresholds(num);
164
+ return score >= t[2] ? 3 : score >= t[1] ? 2 : 1;
165
+ }
166
+
167
+ /** A level run: same step(tap) contract as makeSim; ends with dead=true and won=true/false. */
168
+ export function makeLevelSim(num) {
169
+ const D = LEVELS[num - 1];
170
+ const s = {
171
+ level: num, goal: D.goal, lives: D.lives, i: 0, t: 0, wait: 0, hold: 0, cur: levelBloom(num, 0), r: 0, marks: [],
172
+ score: 0, points: 0, perfect: 0, cleared: 0, dead: false, won: false, last: '', missed: 0,
173
+ busy() { return this.wait > 0 || this.hold > 0; },
174
+ step(tap) {
175
+ if (this.dead) return '';
176
+ if (this.wait > 0) { if (--this.wait === 0) { this.i++; this.cur = levelBloom(num, this.i); this.t = 0; this.r = 0; } return ''; }
177
+ if (this.hold > 0) { this.hold--; return ''; }
178
+ this.t++;
179
+ const nr = radius(this.cur, this.t);
180
+ this.r = nr > this.r ? nr : this.r;
181
+ let res = '';
182
+ if (tap) res = judge(this.cur, this.r, this.t);
183
+ else if (this.r > LIMIT || this.r > this.cur.c + this.cur.w / 2 + 0.12 && this.cur.k < this.cur.rings.length - 1) res = 'miss';
184
+ if (!res) return '';
185
+ this.marks.push(res); this.last = res;
186
+ if (res === 'miss') {
187
+ this.missed++; this.lives--;
188
+ if (this.lives <= 0) { this.dead = true; return res; }
189
+ this.wait = GAP + 6;
190
+ return res;
191
+ }
192
+ this.score++; this.points += res === 'perfect' ? 2 : 1; if (res === 'perfect') this.perfect++;
193
+ const b = this.cur;
194
+ if (b.k < b.rings.length - 1) { b.k++; b.c = b.rings[b.k].c; b.w = b.rings[b.k].w; this.hold = HOLD; return res; }
195
+ this.cleared++;
196
+ if (this.cleared >= this.goal) { this.dead = true; this.won = true; return res; }
197
+ this.wait = GAP;
198
+ return res;
199
+ },
200
+ };
201
+ return s;
202
+ }
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
 
game.js CHANGED
@@ -1,12 +1,14 @@
1
- // Brimbloom: game screen (fixed-tick loop, canvas, sound, daily/practice/archive, share grid, Fun Pass, Skin Pack). (c) 2026 CyberMax.
2
- import { makeSim, grid, ringC, judge, radius, HZ, LIMIT } from './core.js';
3
- import { dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js';
 
 
4
  import * as skins from './skins.js';
5
  import * as pass from './funpass.js';
6
 
7
  const GAME = 'brimbloom';
8
  const PATH = '/games/brimbloom/';
9
- const GUMROAD = 'https://cybermax1.gumroad.com/l/cybermax-brimbloom-skins';
10
  const $ = (id) => document.getElementById(id);
11
  const cv = $('c');
12
  const g = cv.getContext('2d');
@@ -25,44 +27,56 @@ const SKINS = {
25
  const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack');
26
  const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same blooms for everyone on Earth
27
  const todayNum = dayNumber(todayIso);
 
 
 
28
 
29
  skins.capture({ game: GAME, name: 'Brimbloom', skins: PACK.length, price: '$1.99', gumroad: GUMROAD, preview: PACK.map((s) => (s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : s.petals[0])),
30
  perks: [`${PACK.length} flowers with their own pond and petals: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] });
31
- pass.init({ game: GAME, name: 'Brimbloom', perks: ['Every past daily garden in the archive', 'Night and Linen themes'] });
32
  const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned());
33
  let skin = SKINS[store('brimbloom:skin')] && canUse(SKINS[store('brimbloom:skin')]) ? store('brimbloom:skin') : 'pond';
34
  let muted = !!store('brimbloom:mute');
 
 
 
35
  let mode = { kind: 'daily', date: todayIso };
36
- let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shake = 0, burst = 0, lastRun = null;
 
37
  const install = pwa(GAME, $('install'));
38
  themeToggle($('theme'));
39
 
40
- // ------------------------------------------------------------------ sound
41
- let ac;
42
- function blip(f, d = 0.06, type = 'sine', vol = 0.08) {
43
- if (muted) return;
44
- try {
45
- ac = ac || new (window.AudioContext || window.webkitAudioContext)();
46
- const o = ac.createOscillator(), v = ac.createGain();
47
- o.type = type; o.frequency.setValueAtTime(f, ac.currentTime);
48
- o.frequency.exponentialRampToValueAtTime(f * (type === 'sawtooth' ? 0.3 : 1.5), ac.currentTime + d);
49
- v.gain.setValueAtTime(vol, ac.currentTime); v.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + d);
50
- o.connect(v); v.connect(ac.destination); o.start(); o.stop(ac.currentTime + d + 0.02);
51
- } catch { /* no audio */ }
52
- }
53
  $('mute').textContent = muted ? '🔇' : '🔊';
54
- $('mute').onclick = () => { muted = !muted; store('brimbloom:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; };
55
 
56
- // ------------------------------------------------------------------ modes + stats
57
  const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date);
58
  const dayKey = (m) => `brimbloom:day:${m.date}`;
59
  const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0, marks: [] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  function renderModes() {
61
  const el = $('modes'); el.innerHTML = '';
 
62
  const items = [
63
- { t: `Daily #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => setMode({ kind: 'daily', date: todayIso }) },
64
- { t: 'Practice', on: mode.kind === 'practice', go: () => setMode({ kind: 'practice', seed: `p${Date.now()}` }) },
65
  { t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive },
 
 
66
  ];
67
  for (const it of items) {
68
  const b = document.createElement('button');
@@ -76,44 +90,132 @@ function archive() {
76
  const n = todayNum - 1;
77
  if (n < 1) { toast('The archive fills up day by day; the first archived garden arrives tomorrow.', 4000); return; }
78
  const k = Number(prompt(`Play a past daily garden: enter a number from 1 to ${n}`, String(n)));
79
- if (k >= 1 && k <= n) setMode({ kind: 'daily', date: dateOf(k) });
 
 
 
 
 
 
 
 
 
 
 
80
  }
81
- function setMode(m) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  mode = m;
83
- $('num').textContent = m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`;
84
- $('over').hidden = true;
85
- renderModes(); reset(); hud();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  }
87
  function hud() {
88
- const s = mode.kind === 'daily' ? dayStats(mode) : { best: store('brimbloom:practice-best') || 0, tries: 0 };
89
- $('best').textContent = s.best; $('tries').textContent = s.tries || 0;
 
 
 
 
 
90
  }
91
 
92
  // ------------------------------------------------------------------ loop
93
- function reset() { sim = makeSim(seedOf(mode)); state = 'ready'; pending = false; acc = 0; flash = 0; shake = 0; burst = 0; }
 
 
 
94
  function start() {
95
  if (mode.kind === 'practice') mode.seed = `p${Date.now()}`;
96
  reset(); state = 'run'; last = performance.now();
97
- count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice');
98
  $('over').hidden = true;
 
99
  }
 
 
100
  function input() {
101
- if (state === 'ready') { start(); return; }
102
  if (state === 'run') { pending = true; return; }
103
- if (state === 'dead' && performance.now() - deadAt > 300) start();
104
  }
 
 
105
  function die() {
106
- state = 'dead'; deadAt = performance.now(); flash = 1; shake = 12;
107
- lastRun = { score: sim.score, perfect: sim.perfect, marks: sim.marks.slice() };
108
- blip(150, 0.32, 'sawtooth', 0.11);
109
- if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
110
- let newBest = false;
111
- if (mode.kind === 'daily') {
112
- const s = dayStats(mode); s.tries = (s.tries || 0) + 1;
113
- if (sim.score > (s.best || 0) || (sim.score === s.best && sim.perfect > (s.perfect || 0))) { s.best = sim.score; s.perfect = sim.perfect; s.marks = lastRun.marks; newBest = sim.score > 0; }
114
- store(dayKey(mode), s);
115
- } else if (sim.score > (store('brimbloom:practice-best') || 0)) { store('brimbloom:practice-best', sim.score); newBest = true; }
116
- hud(); showOver(newBest);
 
 
 
 
 
 
 
 
 
 
 
 
117
  if (sim.score >= 5) install.offer();
118
  }
119
  function frame(now) {
@@ -123,15 +225,23 @@ function frame(now) {
123
  let n = 0;
124
  while (acc >= 1 / HZ && n++ < 8) {
125
  acc -= 1 / HZ;
126
- const tap = pending && sim.wait === 0; pending = false;
127
- const ev = sim.step(tap);
128
- if (ev === 'perfect') { blip(880 + Math.min(sim.score, 30) * 20, 0.09, 'sine', 0.08); burst = 1; }
129
- else if (ev === 'good') { blip(560 + Math.min(sim.score, 30) * 14, 0.07, 'triangle', 0.07); burst = 0.6; }
130
  if (sim.dead) { die(); break; }
131
  }
132
  } else last = now;
133
  draw(now);
134
  }
 
 
 
 
 
 
 
 
135
 
136
  // ------------------------------------------------------------------ drawing
137
  const TAU = Math.PI * 2;
@@ -155,62 +265,130 @@ function flower(ctx, x, y, R, n, S, hue, spinA) {
155
  ctx.fillStyle = S.core; ctx.beginPath(); ctx.arc(0, 0, Math.max(5, R * 0.16), 0, TAU); ctx.fill();
156
  ctx.restore();
157
  }
158
- function ring(ctx, x, y, Rmax, c, w, S) {
159
  ctx.fillStyle = S.band; ctx.beginPath(); ctx.arc(x, y, (c + w / 2) * Rmax, 0, TAU); ctx.arc(x, y, Math.max(0, c - w / 2) * Rmax, 0, TAU, true); ctx.fill();
160
- ctx.fillStyle = S.sweet; ctx.beginPath(); ctx.arc(x, y, (c + w * 0.25) * Rmax, 0, TAU); ctx.arc(x, y, (c - w * 0.25) * Rmax, 0, TAU, true); ctx.fill();
161
- ctx.strokeStyle = S.edge; ctx.lineWidth = 3; ctx.globalAlpha = 0.7;
 
 
 
 
 
 
 
 
 
162
  for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, r * Rmax, 0, TAU); ctx.stroke(); }
163
- ctx.globalAlpha = 1;
 
 
 
 
 
164
  }
165
  function draw(now) {
166
  const S = SKINS[skin], Wc = cv.width, cx = Wc / 2, cy = Wc / 2 + 20, Rmax = Wc * 0.43;
167
  g.save();
168
- if (shake > 0) { g.translate((Math.random() - 0.5) * shake, (Math.random() - 0.5) * shake); shake *= 0.85; if (shake < 0.5) shake = 0; }
169
  g.fillStyle = S.bg; g.fillRect(-20, -20, Wc + 40, Wc + 40);
170
- // ripples
171
  g.strokeStyle = S.band; g.lineWidth = 2;
172
  for (let i = 0; i < 3; i++) { const r = ((now / 30 + i * 120) % 360); g.globalAlpha = 1 - r / 360; g.beginPath(); g.arc(cx, cy, r + 20, 0, TAU); g.stroke(); }
173
  g.globalAlpha = 1;
174
- const b = sim.cur;
175
- ring(g, cx, cy, Rmax, ringC(b, sim.t), b.w, S);
176
- const R = (state === 'ready' ? 0.18 + Math.sin(now / 300) * 0.02 : Math.min(sim.r, LIMIT)) * Rmax;
 
 
 
 
 
 
 
 
 
 
177
  flower(g, cx, cy, R * (1 + burst * 0.06), b.n, S, b.hue, now / 4000);
178
  burst *= 0.88;
179
  // marks row (this run)
180
  const marks = sim.marks.slice(-14);
181
  marks.forEach((m, i) => { g.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; g.beginPath(); g.arc(cx - (marks.length - 1) * 13 + i * 26, Wc - 26, 8, 0, TAU); g.fill(); });
182
  g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle';
 
 
 
 
 
 
 
183
  if (state === 'ready') {
184
- g.font = '900 60px system-ui, sans-serif'; g.fillText('TAP', cx, 64);
185
- g.font = '700 24px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('then tap again when the bloom fills the ring', cx, 112); g.globalAlpha = 1;
186
  } else {
187
- g.font = '900 72px system-ui, sans-serif'; g.fillText(String(sim.score), cx, 56);
188
- if (sim.wait > 0) { g.font = '900 34px system-ui, sans-serif'; g.fillStyle = sim.last === 'perfect' ? '#3DDC84' : '#FFC93C'; g.fillText(sim.last === 'perfect' ? 'PERFECT' : 'GOOD', cx, 112); }
189
- if (state === 'dead') { g.font = '800 28px system-ui, sans-serif'; g.fillStyle = S.text; g.fillText(sim.r > LIMIT ? 'it overflowed · tap to retry' : 'off the brim · tap to retry', cx, 112); }
 
 
 
 
 
 
 
 
190
  }
191
- if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(0, 0, Wc, Wc); flash *= 0.88; if (flash < 0.02) flash = 0; }
192
  g.restore();
193
  }
194
 
195
  // ------------------------------------------------------------------ results, share, card
196
  function result() {
197
  if (mode.kind === 'daily') { const s = dayStats(mode); return { best: s.best, perfect: s.perfect || 0, marks: s.marks || [], tries: s.tries }; }
198
- return { best: lastRun ? lastRun.score : 0, perfect: lastRun ? lastRun.perfect : 0, marks: lastRun ? lastRun.marks : [] };
199
  }
200
  function shareText() {
201
  const r = result();
 
 
202
  const head = mode.kind === 'practice' ? 'Brimbloom practice 🌸' : `Brimbloom #${dayNumber(mode.date)} 🌸`;
203
- return `${head} ${r.best} blooms, ${r.perfect} perfect${r.tries ? ` (${r.tries} tries)` : ''}\n${grid(r.marks)}\nCan you stop on the brim?`;
204
  }
205
- function showOver(newBest) {
206
- const s = mode.kind === 'daily' ? dayStats(mode) : null;
207
- $('over-title').textContent = newBest ? `New best: ${lastRun.score}!` : `${lastRun.score} bloom${lastRun.score === 1 ? '' : 's'}, ${lastRun.perfect} perfect`;
208
- $('over-line').textContent = s ? `Today's best ${s.best} after ${s.tries} tries. Tap the game to go again.` : 'Practice garden. Tap the game to go again.';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  $('over-grid').textContent = grid(lastRun.marks);
210
- const it = intents(GAME, shareText(), PATH);
 
 
 
 
 
211
  $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
212
  $('over').hidden = false;
213
- pass.afterWin($('over'), 'over');
 
 
214
  }
215
  function card() {
216
  const S = SKINS[skin], c = document.createElement('canvas');
@@ -219,28 +397,47 @@ function card() {
219
  x.fillStyle = S.bg; x.fillRect(0, 0, 1080, 1350);
220
  ring(x, 540, 700, 330, 0.7, 0.16, S);
221
  flower(x, 540, 700, 0.7 * 330, 7, S, 330, 0.2);
222
- const r = result();
223
  x.fillStyle = S.text; x.textAlign = 'left';
224
  x.font = '900 66px system-ui, sans-serif'; x.fillText('BRIMBLOOM', 80, 130);
225
  x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif';
226
- x.fillText(mode.kind === 'daily' ? `Daily garden #${dayNumber(mode.date)} · ${mode.date}` : 'Practice garden', 80, 190);
227
- x.globalAlpha = 1; x.textAlign = 'right'; x.font = '900 120px system-ui, sans-serif'; x.fillText(String(r.best), 1000, 150);
228
- x.font = '700 34px system-ui, sans-serif'; x.fillText(`blooms · ${r.perfect} perfect`, 1000, 195);
229
  x.textAlign = 'left';
230
  const ms = r.marks.slice(0, 30);
231
  ms.forEach((m, i) => { x.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; x.fillRect(80 + (i % 10) * 92, 990 + Math.floor(i / 10) * 52, 76, 40); });
232
- x.fillStyle = S.text; x.font = '800 48px system-ui, sans-serif'; x.fillText('Can you stop on the brim?', 80, 1200);
233
  x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/brimbloom', 80, 1290);
234
  return c;
235
  }
236
- $('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `brimbloom-${mode.date || 'practice'}.png`);
 
 
 
 
 
 
 
 
 
 
 
 
237
  ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
238
- $('card').onclick = async () => { download(await canvasBlob(card()), `brimbloom-${mode.date || 'practice'}.png`); count(GAME, 'share', 'png'); };
 
 
239
 
240
  // ------------------------------------------------------------------ input, skins, start
241
  cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); });
242
  cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); input(); } });
243
- document.addEventListener('keydown', (e) => { if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); } });
 
 
 
 
 
244
  $('pass-btn').onclick = () => pass.open('header');
245
  $('skins-btn').onclick = () => skins.open('header');
246
  document.addEventListener('click', (e) => {
@@ -265,12 +462,13 @@ function renderSkins() {
265
  }
266
  $('skins-btn').textContent = skins.owned() ? '🌸 Skins' : '🌸 Skin Pack $1.99';
267
  }
268
- const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'pond'; store('brimbloom:skin', 'pond'); } renderSkins(); renderModes(); };
269
  pass.onChange(refresh);
270
  skins.onChange(refresh);
271
 
272
  // Screenshot helper for scripts/games/render_assets.mjs: a mid-run frame on today's garden (no effect on normal play).
273
  window.__cmxShot = async (stage = 1) => {
 
274
  start();
275
  const s = makeSim(todayIso);
276
  while (s.score < 4 && !s.dead) { const t = s.t + 1; s.step(s.wait === 0 && judge(s.cur, Math.max(s.r, radius(s.cur, t)), t) === 'perfect'); }
@@ -280,17 +478,20 @@ window.__cmxShot = async (stage = 1) => {
280
  if (stage === 2) { s.marks.push('miss'); s.dead = true; die(); }
281
  };
282
 
283
- // Demo recorder helper (scripts/studio/demo_video.mjs): real taps (pointer events) on perfect moments for n blooms,
284
- // then one early tap so the run ends on screen. Cosmetic games only; there is no leaderboard to game.
285
  window.__cmxDemo = (n = 6) => {
286
  const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
287
  if (state !== 'run') tap();
288
  const iv = setInterval(() => {
289
  if (state === 'dead') { clearInterval(iv); return; }
290
- if (state !== 'run' || sim.wait > 0 || pending) return;
291
  const t = sim.t + 1;
292
  if (sim.score >= n ? sim.t > 12 : judge(sim.cur, Math.max(sim.r, radius(sim.cur, t)), t) === 'perfect') tap();
293
  }, 4);
294
  };
295
- setMode(mode);
 
 
 
296
  requestAnimationFrame(frame);
 
1
+ // Brimbloom: game screen (fixed-tick loop, canvas, sound, 30 levels, daily/practice/archive, streak, share grid,
2
+ // beat-my-score links, Fun Pass, Skin Pack). (c) 2026 CyberMax.
3
+ import { makeSim, grid, ringC, judge, radius, HZ, LIMIT, LEVELS, makeLevelSim, levelStars, levelThresholds } from './core.js';
4
+ import * as lv from './levels.js';
5
+ import { dayNumber, dateOf, count, store, toast, intents, download, canvasBlob, pwa, themeToggle } from './cmx.js';
6
  import * as skins from './skins.js';
7
  import * as pass from './funpass.js';
8
 
9
  const GAME = 'brimbloom';
10
  const PATH = '/games/brimbloom/';
11
+ const GUMROAD = 'https://cybermaxtools.gumroad.com/l/cybermax-brimbloom-skins';
12
  const $ = (id) => document.getElementById(id);
13
  const cv = $('c');
14
  const g = cv.getContext('2d');
 
27
  const PACK = Object.values(SKINS).filter((s) => s.tier === 'pack');
28
  const todayIso = new Date().toISOString().slice(0, 10); // UTC day: the same blooms for everyone on Earth
29
  const todayNum = dayNumber(todayIso);
30
+ const NL = LEVELS.length;
31
+ const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss }));
32
+ const WORLDS = ['Spring pond', 'Summer pond', 'Night garden (Fun Pass)'];
33
 
34
  skins.capture({ game: GAME, name: 'Brimbloom', skins: PACK.length, price: '$1.99', gumroad: GUMROAD, preview: PACK.map((s) => (s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : s.petals[0])),
35
  perks: [`${PACK.length} flowers with their own pond and petals: ${PACK.map((s) => s.name).join(', ')}`, 'Every skin also on your share card'] });
36
+ pass.init({ game: GAME, name: 'Brimbloom', perks: ['Levels 21-30: fading rings, thorn rings and the Full bloom finale', 'Every past daily garden in the archive', 'Night and Linen themes'] });
37
  const canUse = (s) => s.tier === 'free' || (s.tier === 'pass' && pass.isPro()) || (s.tier === 'pack' && skins.owned());
38
  let skin = SKINS[store('brimbloom:skin')] && canUse(SKINS[store('brimbloom:skin')]) ? store('brimbloom:skin') : 'pond';
39
  let muted = !!store('brimbloom:mute');
40
+ const sfx = lv.sfx(() => muted);
41
+ const prog = lv.progress(GAME, { total: NL, free: 20, isPro: () => pass.isPro() });
42
+ const chal = lv.readChallenge(); // ?seed=L7&score=12 or ?seed=2026-10-08&score=9 ("beat my score")
43
  let mode = { kind: 'daily', date: todayIso };
44
+ let sim, state = 'ready', pending = false, acc = 0, last = 0, deadAt = 0, flash = 0, shakeAmt = 0, burst = 0, lastRun = null, pop = null;
45
+ lv.css();
46
  const install = pwa(GAME, $('install'));
47
  themeToggle($('theme'));
48
 
49
+ function setMute(v) { muted = v; store('brimbloom:mute', muted); $('mute').textContent = muted ? '🔇' : '🔊'; }
 
 
 
 
 
 
 
 
 
 
 
 
50
  $('mute').textContent = muted ? '🔇' : '🔊';
51
+ $('mute').onclick = () => setMute(!muted);
52
 
53
+ // ------------------------------------------------------------------ modes, stats, streak
54
  const seedOf = (m) => (m.kind === 'practice' ? m.seed : m.date);
55
  const dayKey = (m) => `brimbloom:day:${m.date}`;
56
  const dayStats = (m) => store(dayKey(m)) || { best: 0, tries: 0, marks: [] };
57
+ const played = (n) => { const s = n >= 1 && store(`brimbloom:day:${dateOf(n)}`); return !!(s && s.tries > 0); };
58
+ /** Days in a row with at least one daily run (counting from today, or from yesterday while today is unplayed). */
59
+ function streakNow() {
60
+ let d = played(todayNum) ? todayNum : todayNum - 1, n = 0;
61
+ while (played(d)) { n++; d--; }
62
+ return n;
63
+ }
64
+ function anyDaily() {
65
+ try { for (let i = 0; i < localStorage.length; i++) if (localStorage.key(i).startsWith('brimbloom:day:')) return true; } catch { /* */ }
66
+ return false;
67
+ }
68
+ const isLv = () => mode.kind === 'level';
69
+ const LD = () => LEVELS[mode.level - 1];
70
+
71
  function renderModes() {
72
  const el = $('modes'); el.innerHTML = '';
73
+ const st = streakNow();
74
  const items = [
75
+ { t: `Daily #${todayNum}${st ? ` 🔥${st}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso, go: () => load({ kind: 'daily', date: todayIso }, true) },
76
+ { t: 'Practice', on: mode.kind === 'practice', go: () => load({ kind: 'practice', seed: `p${Date.now()}` }, true) },
77
  { t: 'Archive', on: mode.kind === 'daily' && mode.date !== todayIso, lock: !pass.isPro(), go: archive },
78
+ // after Practice/Archive so their positions stay where the shared latchkite/brimbloom e2e expects them
79
+ { t: `Levels ${prog.cleared()}/${NL} ⭐${prog.totalStars()}`, on: isLv(), go: showMap },
80
  ];
81
  for (const it of items) {
82
  const b = document.createElement('button');
 
90
  const n = todayNum - 1;
91
  if (n < 1) { toast('The archive fills up day by day; the first archived garden arrives tomorrow.', 4000); return; }
92
  const k = Number(prompt(`Play a past daily garden: enter a number from 1 to ${n}`, String(n)));
93
+ if (k >= 1 && k <= n) load({ kind: 'daily', date: dateOf(k) }, true);
94
+ }
95
+ function showMap() {
96
+ const el = $('levels');
97
+ if (!el.hidden) { el.hidden = true; return; }
98
+ lv.renderMap($('lvmap'), prog, MAP, {
99
+ current: isLv() ? mode.level : 0, worlds: WORLDS,
100
+ onPick: (k) => { load({ kind: 'level', level: k }, true); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); },
101
+ onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)),
102
+ });
103
+ el.hidden = false;
104
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
105
  }
106
+
107
+ function fromUrl() {
108
+ const q = new URLSearchParams(location.search);
109
+ if (chal && chal.level >= 1 && chal.level <= NL) return { kind: 'level', level: chal.level, challenge: true };
110
+ if (chal && chal.date) return { kind: 'daily', date: chal.date <= todayIso && (pass.isPro() || chal.date === todayIso) ? chal.date : todayIso };
111
+ const l = Number(q.get('lv'));
112
+ if (l >= 1 && l <= NL) return { kind: 'level', level: l };
113
+ // first visit: level 1 with the 2-second tutorial; returning players land on today's daily garden
114
+ if (!anyDaily() && !prog.cleared()) return { kind: 'level', level: 1 };
115
+ return { kind: 'daily', date: todayIso };
116
+ }
117
+
118
+ function load(m, push = false) {
119
+ if (m.kind === 'level') {
120
+ const lk = prog.lock(m.level, { challenge: !!m.challenge });
121
+ if (lk === 'progress') { toast(`Clear level ${prog.reached()} first.`); return false; }
122
+ if (lk) { pass.open('level'); return false; }
123
+ prog.open(m.level);
124
+ }
125
  mode = m;
126
+ $('num').textContent = isLv() ? `Level ${m.level}` : m.kind === 'practice' ? 'practice' : `#${dayNumber(m.date)}`;
127
+ $('over').hidden = true; $('levels').hidden = true;
128
+ if (push) {
129
+ const u = new URL(location.href);
130
+ u.search = isLv() ? `?lv=${m.level}` : '';
131
+ history.replaceState(null, '', u.pathname + u.search);
132
+ }
133
+ renderModes(); reset(); hud(); goal(); banner();
134
+ if (isLv() && m.level === 1 && !prog.stars(1)) setTimeout(() => lv.tutorial(GAME, cv, { fx: 0.5, fy: 0.62 }), 250);
135
+ if (isLv() && LD().boss) sfx.boss();
136
+ return true;
137
+ }
138
+
139
+ /** The goal line: what this run asks for, with live progress. */
140
+ function goal() {
141
+ const bits = [];
142
+ if (isLv()) {
143
+ const D = LD(), t = levelThresholds(mode.level);
144
+ if (D.boss) bits.push('👑 Challenge level');
145
+ bits.push(`🎯 Bloom <b id="gp">${sim.cleared}/${D.goal}</b> flowers`);
146
+ bits.push(`⭐⭐⭐ at <b>${t[2]}</b> pts`);
147
+ if (D.twist) bits.push(`<span class="new-twist">NEW: ${D.twist}</span>`);
148
+ } else {
149
+ bits.push(`🎯 Bloom as many as you can, one miss ends the run · <b id="gp">${sim.score}</b>`);
150
+ if (mode.kind === 'daily' && mode.date === todayIso && streakNow()) bits.push(`🔥 ${streakNow()}-day streak`);
151
+ }
152
+ $('goal').innerHTML = `<span>${bits.join(' · ')}</span>`;
153
+ liveGoal();
154
+ }
155
+ function liveGoal() {
156
+ const gp = $('gp'); if (gp) gp.textContent = isLv() ? `${sim.cleared}/${sim.goal}` : sim.score;
157
+ }
158
+ function banner() {
159
+ const el = $('challenge');
160
+ const same = chal && chal.score !== null && ((chal.level && isLv() && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date));
161
+ el.hidden = !same;
162
+ if (same) el.innerHTML = `<span>��� A friend scored <b>${chal.score}</b> ${isLv() ? `points on level ${chal.level}` : `blooms on daily #${dayNumber(chal.date)}`}. Beat it!</span>`;
163
  }
164
  function hud() {
165
+ if (isLv()) {
166
+ const b = prog.best(mode.level), st = prog.stars(mode.level);
167
+ $('stat').innerHTML = `${LD().name} · best <b>${b ?? '–'}</b> pts ${lv.starText(st)}`;
168
+ } else if (mode.kind === 'daily') {
169
+ const s = dayStats(mode);
170
+ $('stat').innerHTML = `Today's best <b id="best">${s.best}</b> · tries <b id="tries">${s.tries || 0}</b>`;
171
+ } else $('stat').innerHTML = `Practice best <b>${store('brimbloom:practice-best') || 0}</b>`;
172
  }
173
 
174
  // ------------------------------------------------------------------ loop
175
+ function reset() {
176
+ sim = isLv() ? makeLevelSim(mode.level) : makeSim(seedOf(mode));
177
+ state = 'ready'; pending = false; acc = 0; flash = 0; shakeAmt = 0; burst = 0; pop = null;
178
+ }
179
  function start() {
180
  if (mode.kind === 'practice') mode.seed = `p${Date.now()}`;
181
  reset(); state = 'run'; last = performance.now();
182
+ count(GAME, 'play', isLv() ? `level-${mode.level}` : mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : 'practice');
183
  $('over').hidden = true;
184
+ goal();
185
  }
186
+ /** Instant restart (R key, Retry button, or a tap after the run): straight into a fresh run. */
187
+ function restart() { sfx.tap(); start(); }
188
  function input() {
189
+ if (state === 'ready') { sfx.tap(); start(); return; }
190
  if (state === 'run') { pending = true; return; }
191
+ if (state === 'dead' && performance.now() - deadAt > (sim.won ? 600 : 250)) restart();
192
  }
193
+ const flowerY = () => (cv.width / 2 + 20) / cv.width;
194
+ function petalCols() { const S = SKINS[skin]; return S.petals === 'hue' ? ['#FF5A5A', '#FFD84D', '#5CF2FF', '#C37CFF'] : [...S.petals, S.core, '#FFFFFF']; }
195
  function die() {
196
+ state = 'dead'; deadAt = performance.now();
197
+ lastRun = { score: sim.score, perfect: sim.perfect, marks: sim.marks.slice(), points: sim.points, won: !!sim.won, cleared: sim.cleared };
198
+ let newBest = false, res = null;
199
+ if (isLv()) {
200
+ const st = levelStars(mode.level, sim.points, sim.won);
201
+ res = prog.record(mode.level, st, sim.points);
202
+ res.got = st;
203
+ if (sim.won) {
204
+ sfx.win(); for (let k = 0; k < st; k++) sfx.star(k + 1);
205
+ lv.burstAt(cv, 0.5, flowerY(), { n: 70, speed: 10, colors: petalCols() });
206
+ count(GAME, 'win', `level-${mode.level}`);
207
+ } else { flash = 1; shakeAmt = 12; sfx.lose(); lv.shake(cv, 10, 320); }
208
+ newBest = res.improved;
209
+ } else {
210
+ flash = 1; shakeAmt = 12; sfx.hit(); lv.shake(cv, 10, 320);
211
+ if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
212
+ if (mode.kind === 'daily') {
213
+ const s = dayStats(mode); s.tries = (s.tries || 0) + 1;
214
+ if (sim.score > (s.best || 0) || (sim.score === s.best && sim.perfect > (s.perfect || 0))) { s.best = sim.score; s.perfect = sim.perfect; s.marks = lastRun.marks; newBest = sim.score > 0; }
215
+ store(dayKey(mode), s);
216
+ } else if (sim.score > (store('brimbloom:practice-best') || 0)) { store('brimbloom:practice-best', sim.score); newBest = true; }
217
+ }
218
+ renderModes(); hud(); goal(); showOver(newBest, res);
219
  if (sim.score >= 5) install.offer();
220
  }
221
  function frame(now) {
 
225
  let n = 0;
226
  while (acc >= 1 / HZ && n++ < 8) {
227
  acc -= 1 / HZ;
228
+ const busy = sim.busy ? sim.busy() : sim.wait > 0;
229
+ const tap = pending && !busy; pending = false;
230
+ const rAt = sim.r, ev = sim.step(tap);
231
+ if (ev) onEvent(ev, rAt);
232
  if (sim.dead) { die(); break; }
233
  }
234
  } else last = now;
235
  draw(now);
236
  }
237
+ function onEvent(ev, rAt) {
238
+ const k = sim.score;
239
+ const fy = flowerY() - Math.min(rAt, 1) * 0.43 * 0.7;
240
+ if (ev === 'perfect') { sfx.good(k); burst = 1; lv.burstAt(cv, 0.5, fy, { n: 18, speed: 5, colors: petalCols(), life: 500 }); pop = { t: performance.now(), txt: 'PERFECT', c: '#3DDC84' }; }
241
+ else if (ev === 'good') { sfx.tone(560 + Math.min(k, 30) * 14, 0.07, 'triangle', 0.06, 1.4); burst = 0.6; pop = { t: performance.now(), txt: 'GOOD', c: '#FFC93C' }; }
242
+ else if (ev === 'miss' && !sim.dead) { sfx.hit(); flash = 0.8; shakeAmt = 10; lv.shake(cv, 8, 260); pop = { t: performance.now(), txt: sim.r > LIMIT ? 'OVERFLOW' : 'MISS', c: '#FF6B6B' }; }
243
+ liveGoal();
244
+ }
245
 
246
  // ------------------------------------------------------------------ drawing
247
  const TAU = Math.PI * 2;
 
265
  ctx.fillStyle = S.core; ctx.beginPath(); ctx.arc(0, 0, Math.max(5, R * 0.16), 0, TAU); ctx.fill();
266
  ctx.restore();
267
  }
268
+ function ring(ctx, x, y, Rmax, c, w, S, lit = false) {
269
  ctx.fillStyle = S.band; ctx.beginPath(); ctx.arc(x, y, (c + w / 2) * Rmax, 0, TAU); ctx.arc(x, y, Math.max(0, c - w / 2) * Rmax, 0, TAU, true); ctx.fill();
270
+ ctx.fillStyle = S.sweet; ctx.beginPath(); ctx.arc(x, y, (c + w * 0.25) * Rmax, 0, TAU); ctx.arc(x, y, Math.max(0, c - w * 0.25) * Rmax, 0, TAU, true); ctx.fill();
271
+ const a0 = ctx.globalAlpha;
272
+ ctx.strokeStyle = S.edge; ctx.lineWidth = lit ? 6 : 3; ctx.globalAlpha = a0 * (lit ? 1 : 0.7);
273
+ for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, Math.max(0, r) * Rmax, 0, TAU); ctx.stroke(); }
274
+ ctx.globalAlpha = a0;
275
+ }
276
+ /** A thorn ring (decoy): a red dashed band that looks like a brim but never counts. */
277
+ function thorns(ctx, x, y, Rmax, c, w) {
278
+ ctx.save();
279
+ ctx.fillStyle = 'rgba(255,90,90,.13)'; ctx.beginPath(); ctx.arc(x, y, (c + w / 2) * Rmax, 0, TAU); ctx.arc(x, y, (c - w / 2) * Rmax, 0, TAU, true); ctx.fill();
280
+ ctx.strokeStyle = '#FF6B6B'; ctx.lineWidth = 3; ctx.setLineDash([10, 9]); ctx.globalAlpha = 0.85;
281
  for (const r of [c - w / 2, c + w / 2]) { ctx.beginPath(); ctx.arc(x, y, r * Rmax, 0, TAU); ctx.stroke(); }
282
+ ctx.restore();
283
+ }
284
+ function hearts(n, of, x, y) {
285
+ g.font = '26px system-ui, "Apple Color Emoji", "Segoe UI Emoji", sans-serif'; g.textAlign = 'left';
286
+ for (let i = 0; i < of; i++) { g.globalAlpha = i < n ? 1 : 0.3; g.fillText(i < n ? '❤️' : '🖤', x + i * 32, y); }
287
+ g.globalAlpha = 1;
288
  }
289
  function draw(now) {
290
  const S = SKINS[skin], Wc = cv.width, cx = Wc / 2, cy = Wc / 2 + 20, Rmax = Wc * 0.43;
291
  g.save();
292
+ if (shakeAmt > 0) { g.translate((Math.random() - 0.5) * shakeAmt, (Math.random() - 0.5) * shakeAmt); shakeAmt *= 0.85; if (shakeAmt < 0.5) shakeAmt = 0; }
293
  g.fillStyle = S.bg; g.fillRect(-20, -20, Wc + 40, Wc + 40);
 
294
  g.strokeStyle = S.band; g.lineWidth = 2;
295
  for (let i = 0; i < 3; i++) { const r = ((now / 30 + i * 120) % 360); g.globalAlpha = 1 - r / 360; g.beginPath(); g.arc(cx, cy, r + 20, 0, TAU); g.stroke(); }
296
  g.globalAlpha = 1;
297
+ const b = sim.cur, rNow = state === 'ready' ? 0 : Math.min(sim.r, LIMIT);
298
+ if (b.decoy) thorns(g, cx, cy, Rmax, b.decoy.c, b.decoy.w);
299
+ if (b.rings && b.rings.length > 1) {
300
+ b.rings.forEach((rg, k) => {
301
+ if (k === b.k) return;
302
+ g.globalAlpha = k < b.k ? 0.25 : 0.55; ring(g, cx, cy, Rmax, rg.c, rg.w, S); g.globalAlpha = 1;
303
+ });
304
+ }
305
+ const c0 = ringC(b, sim.t), inside = state === 'run' && Math.abs(rNow - c0) <= b.w / 2;
306
+ g.globalAlpha = b.fade && state !== 'ready' ? Math.max(0.06, Math.min(1, (c0 - rNow - 0.06) / 0.2)) : 1;
307
+ ring(g, cx, cy, Rmax, c0, b.w, S, inside);
308
+ g.globalAlpha = 1;
309
+ const R = (state === 'ready' ? 0.18 + Math.sin(now / 300) * 0.02 : rNow) * Rmax;
310
  flower(g, cx, cy, R * (1 + burst * 0.06), b.n, S, b.hue, now / 4000);
311
  burst *= 0.88;
312
  // marks row (this run)
313
  const marks = sim.marks.slice(-14);
314
  marks.forEach((m, i) => { g.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; g.beginPath(); g.arc(cx - (marks.length - 1) * 13 + i * 26, Wc - 26, 8, 0, TAU); g.fill(); });
315
  g.fillStyle = S.text; g.textAlign = 'center'; g.textBaseline = 'middle';
316
+ if (isLv()) {
317
+ // goal progress: one dot per flower, hearts top-left, level top-right
318
+ const D = LD(), n = D.goal, w = Math.min(34, 420 / n), x0 = cx - (n * w) / 2;
319
+ for (let i = 0; i < n; i++) { g.fillStyle = i < sim.cleared ? '#3DDC84' : S.band; g.beginPath(); g.arc(x0 + w * i + w / 2, 124, Math.min(9, w * 0.32), 0, TAU); g.fill(); }
320
+ g.fillStyle = S.text; hearts(sim.lives, D.lives, 22, 34);
321
+ g.textAlign = 'right'; g.font = '800 24px system-ui, sans-serif'; g.fillText(`${D.boss ? '👑 ' : ''}Lv ${mode.level}`, Wc - 22, 34); g.textAlign = 'center';
322
+ }
323
  if (state === 'ready') {
324
+ g.font = '900 60px system-ui, sans-serif'; g.fillText('TAP', cx, 60);
325
+ g.font = '700 24px system-ui, sans-serif'; g.globalAlpha = 0.85; g.fillText('then tap again when the petals reach the ring', cx, isLv() ? 162 : 108); g.globalAlpha = 1;
326
  } else {
327
+ g.font = '900 64px system-ui, sans-serif'; g.fillText(isLv() ? `${sim.cleared}/${sim.goal}` : String(sim.score), cx, 64);
328
+ const age = pop ? now - pop.t : 1e9;
329
+ if (pop && age < 700 && state === 'run') { g.globalAlpha = 1 - age / 700; g.font = `900 ${34 + (1 - age / 700) * 8}px system-ui, sans-serif`; g.fillStyle = pop.c; g.fillText(pop.txt, cx, isLv() ? 166 : 118); g.globalAlpha = 1; }
330
+ if (state === 'dead') {
331
+ g.fillStyle = 'rgba(0,0,0,.5)'; g.fillRect(-20, cy - 70, Wc + 40, 140);
332
+ g.fillStyle = '#FFFFFF'; g.font = '900 44px system-ui, sans-serif';
333
+ const won = isLv() && sim.won;
334
+ g.fillText(won ? `LEVEL ${mode.level} CLEAR` : isLv() ? 'OUT OF HEARTS' : sim.r > LIMIT ? 'IT OVERFLOWED' : 'OFF THE BRIM', cx, cy - 22);
335
+ g.font = '700 26px system-ui, sans-serif';
336
+ g.fillText(won ? `${lv.starText(levelStars(mode.level, sim.points))} ${sim.points} pts · tap to replay` : 'tap or press R to retry', cx, cy + 26);
337
+ }
338
  }
339
+ if (flash > 0) { g.fillStyle = `rgba(255,77,77,${flash * 0.3})`; g.fillRect(-20, -20, Wc + 40, Wc + 40); flash *= 0.88; if (flash < 0.02) flash = 0; }
340
  g.restore();
341
  }
342
 
343
  // ------------------------------------------------------------------ results, share, card
344
  function result() {
345
  if (mode.kind === 'daily') { const s = dayStats(mode); return { best: s.best, perfect: s.perfect || 0, marks: s.marks || [], tries: s.tries }; }
346
+ return { best: lastRun ? (isLv() ? lastRun.points : lastRun.score) : 0, perfect: lastRun ? lastRun.perfect : 0, marks: lastRun ? lastRun.marks : [] };
347
  }
348
  function shareText() {
349
  const r = result();
350
+ if (isLv()) return `Brimbloom level ${mode.level} 🌸 ${lv.starText(prog.stars(mode.level))}\n${lastRun.points} points, ${lastRun.perfect} perfect\n${grid(lastRun.marks)}\nCan you beat my ${lastRun.points}?`;
351
+ const st = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` · 🔥${streakNow()}` : '';
352
  const head = mode.kind === 'practice' ? 'Brimbloom practice 🌸' : `Brimbloom #${dayNumber(mode.date)} 🌸`;
353
+ return `${head} ${r.best} blooms, ${r.perfect} perfect${r.tries ? ` (${r.tries} tries)` : ''}${st}\n${grid(r.marks)}\nCan you stop on the brim?`;
354
  }
355
+ /** The "beat my score" link: the same level or daily garden, with my score to beat. */
356
+ function beatLink() {
357
+ if (isLv()) return lv.challengeLink(PATH, GAME, { level: mode.level, score: lastRun ? lastRun.points : 0 });
358
+ if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: result().best });
359
+ return `https://cybermaxtools.com${PATH}`;
360
+ }
361
+ function showOver(newBest, res) {
362
+ const lvl = isLv(), won = lvl && lastRun.won;
363
+ const st = mode.kind === 'daily' ? dayStats(mode) : null;
364
+ $('win-stars').hidden = !won;
365
+ if (lvl) {
366
+ $('win-stars').innerHTML = lv.starsHtml(res.got);
367
+ $('over-title').textContent = won ? (res.got === 3 ? `Level ${mode.level}: perfect bloom!` : `Level ${mode.level} cleared!`) : `Out of hearts on level ${mode.level}`;
368
+ const t = levelThresholds(mode.level);
369
+ $('over-line').textContent = won
370
+ ? `${lastRun.points} points, ${lastRun.perfect} perfect${newBest ? ' · new best' : ''}.${res.got < 3 ? ` ${t[res.got]} points for the next star.` : ''}`
371
+ : `${lastRun.cleared}/${LD().goal} flowers. Tap the game or press R to retry.`;
372
+ } else {
373
+ $('over-title').textContent = newBest ? `New best: ${lastRun.score}!` : `${lastRun.score} bloom${lastRun.score === 1 ? '' : 's'}, ${lastRun.perfect} perfect`;
374
+ $('over-line').textContent = st ? `Today's best ${st.best} after ${st.tries} tries${streakNow() > 1 ? ` · 🔥 ${streakNow()}-day streak` : ''}. Tap the game to go again.` : 'Practice garden. Tap the game to go again.';
375
+ }
376
+ if (chal && chal.score !== null && !$('challenge').hidden) {
377
+ const mine = lvl ? lastRun.points : lastRun.score;
378
+ $('over-line').textContent += mine > chal.score ? ` 🎯 You beat your friend's ${chal.score}!` : mine === chal.score ? ` 🎯 Tied with your friend (${chal.score}).` : ` 🎯 ${chal.score - mine} short of your friend's ${chal.score}. Retry?`;
379
+ }
380
  $('over-grid').textContent = grid(lastRun.marks);
381
+ const next = lvl && mode.level < NL ? mode.level + 1 : 0;
382
+ $('next-lv').hidden = !(won && next);
383
+ if (next) $('next-lv').textContent = prog.lock(next) === 'pass' ? `Level ${next} 🔒 Fun Pass` : `Next: level ${next} ▶`;
384
+ $('beat').hidden = lvl && !won;
385
+ $('share-row').hidden = lvl && !won;
386
+ const it = intents(GAME, `${shareText()}\n🎯 ${beatLink()}`, PATH);
387
  $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
388
  $('over').hidden = false;
389
+ const value = won || newBest;
390
+ if (value) pass.afterWin($('over'), lvl ? 'level' : 'over');
391
+ const fa = $('over').querySelector('.fp-after'); if (fa) fa.hidden = !value;
392
  }
393
  function card() {
394
  const S = SKINS[skin], c = document.createElement('canvas');
 
397
  x.fillStyle = S.bg; x.fillRect(0, 0, 1080, 1350);
398
  ring(x, 540, 700, 330, 0.7, 0.16, S);
399
  flower(x, 540, 700, 0.7 * 330, 7, S, 330, 0.2);
400
+ const r = result(), lvl = isLv();
401
  x.fillStyle = S.text; x.textAlign = 'left';
402
  x.font = '900 66px system-ui, sans-serif'; x.fillText('BRIMBLOOM', 80, 130);
403
  x.globalAlpha = 0.85; x.font = '700 40px system-ui, sans-serif';
404
+ x.fillText(lvl ? `Level ${mode.level} · ${LD().name} ${lv.starText(prog.stars(mode.level))}` : mode.kind === 'daily' ? `Daily garden #${dayNumber(mode.date)} · ${mode.date}` : 'Practice garden', 80, 190);
405
+ x.globalAlpha = 1; x.textAlign = 'right'; x.font = '900 120px system-ui, sans-serif'; x.fillText(String(r.best), 1000, lvl ? 330 : 150);
406
+ x.font = '700 34px system-ui, sans-serif'; x.fillText(lvl ? `points · ${r.perfect} perfect` : `blooms · ${r.perfect} perfect`, 1000, lvl ? 375 : 195);
407
  x.textAlign = 'left';
408
  const ms = r.marks.slice(0, 30);
409
  ms.forEach((m, i) => { x.fillStyle = m === 'perfect' ? '#3DDC84' : m === 'good' ? '#FFC93C' : '#FF4D4D'; x.fillRect(80 + (i % 10) * 92, 990 + Math.floor(i / 10) * 52, 76, 40); });
410
+ x.fillStyle = S.text; x.font = '800 48px system-ui, sans-serif'; x.fillText(lvl ? 'Can you beat my score?' : 'Can you stop on the brim?', 80, 1200);
411
  x.globalAlpha = 0.75; x.font = '600 32px system-ui, sans-serif'; x.fillText('Play free: cybermaxtools.com/games/brimbloom', 80, 1290);
412
  return c;
413
  }
414
+ const fname = () => `brimbloom-${isLv() ? `level-${mode.level}` : mode.date || 'practice'}.png`;
415
+ $('share').onclick = async () => {
416
+ const r = await lv.shareChallenge(shareText(), beatLink(), await canvasBlob(card()), fname());
417
+ if (r === 'copied') toast('Result and link copied. Paste it anywhere.');
418
+ if (r !== 'cancelled') count(GAME, 'share', r === 'shared' ? 'native' : 'copy');
419
+ };
420
+ $('beat').onclick = async () => {
421
+ const mine = isLv() ? lastRun.points : result().best;
422
+ const what = isLv() ? `${mine} points on Brimbloom level ${mode.level}` : `${mine} blooms on Brimbloom #${dayNumber(mode.date || todayIso)}`;
423
+ const r = await lv.shareChallenge(`I scored ${what}. Can you beat me? 🌸`, beatLink());
424
+ if (r === 'copied') toast('Challenge link copied. Send it to a friend!');
425
+ if (r !== 'cancelled') count(GAME, 'share', 'challenge');
426
+ };
427
  ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
428
+ $('card').onclick = async () => { download(await canvasBlob(card()), fname()); count(GAME, 'share', 'png'); };
429
+ $('retry').onclick = () => { restart(); cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
430
+ $('next-lv').onclick = () => { if (load({ kind: 'level', level: mode.level + 1 }, true)) cv.scrollIntoView({ behavior: 'smooth', block: 'center' }); };
431
 
432
  // ------------------------------------------------------------------ input, skins, start
433
  cv.addEventListener('pointerdown', (e) => { e.preventDefault(); input(); });
434
  cv.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); input(); } });
435
+ document.addEventListener('keydown', (e) => {
436
+ if (e.target && /INPUT|TEXTAREA|SELECT/.test(e.target.tagName)) return;
437
+ if (e.key === ' ' && document.activeElement === document.body) { e.preventDefault(); input(); }
438
+ else if ((e.key === 'r' || e.key === 'R') && !e.ctrlKey && !e.metaKey) { e.preventDefault(); restart(); }
439
+ else if ((e.key === 'm' || e.key === 'M') && !e.ctrlKey && !e.metaKey) setMute(!muted);
440
+ });
441
  $('pass-btn').onclick = () => pass.open('header');
442
  $('skins-btn').onclick = () => skins.open('header');
443
  document.addEventListener('click', (e) => {
 
462
  }
463
  $('skins-btn').textContent = skins.owned() ? '🌸 Skins' : '🌸 Skin Pack $1.99';
464
  }
465
+ const refresh = () => { if (!canUse(SKINS[skin])) { skin = 'pond'; store('brimbloom:skin', 'pond'); } renderSkins(); if (sim) renderModes(); };
466
  pass.onChange(refresh);
467
  skins.onChange(refresh);
468
 
469
  // Screenshot helper for scripts/games/render_assets.mjs: a mid-run frame on today's garden (no effect on normal play).
470
  window.__cmxShot = async (stage = 1) => {
471
+ if (mode.kind !== 'daily') load({ kind: 'daily', date: todayIso });
472
  start();
473
  const s = makeSim(todayIso);
474
  while (s.score < 4 && !s.dead) { const t = s.t + 1; s.step(s.wait === 0 && judge(s.cur, Math.max(s.r, radius(s.cur, t)), t) === 'perfect'); }
 
478
  if (stage === 2) { s.marks.push('miss'); s.dead = true; die(); }
479
  };
480
 
481
+ // Demo recorder helper (scripts/studio/demo_video.mjs) and e2e hook: real taps (pointer events) on perfect moments for
482
+ // n stops, then one early tap so a daily run ends on screen (a level run ends by itself when its goal is met).
483
  window.__cmxDemo = (n = 6) => {
484
  const tap = () => cv.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }));
485
  if (state !== 'run') tap();
486
  const iv = setInterval(() => {
487
  if (state === 'dead') { clearInterval(iv); return; }
488
+ if (state !== 'run' || (sim.busy ? sim.busy() : sim.wait > 0) || pending) return;
489
  const t = sim.t + 1;
490
  if (sim.score >= n ? sim.t > 12 : judge(sim.cur, Math.max(sim.r, radius(sim.cur, t)), t) === 'perfect') tap();
491
  }, 4);
492
  };
493
+ window.__cmxTest = { state: () => ({ state, kind: mode.kind, level: mode.level || 0, score: sim.score, cleared: sim.cleared || 0, lives: sim.lives, won: !!sim.won }) };
494
+
495
+ // a locked link (pass level, level not reached yet) falls back to the next open level, then today's daily
496
+ load(fromUrl()) || (!prog.lock(prog.reached()) && load({ kind: 'level', level: prog.reached() })) || load({ kind: 'daily', date: todayIso });
497
  requestAnimationFrame(frame);
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>Brimbloom: one-tap timing game, stop the bloom on the brim | CyberMax</title>
4
- <meta name="description" content="Brimbloom is a free one-tap timing game: a flower blooms outward, tap to stop it inside the ring. Same blooms for everyone each day, rounds of seconds and a Wordle-style share grid. Works offline, installs on your phone.">
5
  <link rel="canonical" href="https://cybermaxtools.com/games/brimbloom/">
6
  <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
- <meta property="og:title" content="Brimbloom: stop on the brim">
8
- <meta property="og:description" content="One tap stops the bloom. Inside the ring and it scores; miss once and it's over. Everyone gets the same garden today.">
9
  <meta property="og:url" content="https://cybermaxtools.com/games/brimbloom/">
10
  <meta property="og:image" content="https://cybermaxtools.com/games/brimbloom/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/brimbloom/og.png">
@@ -15,42 +15,61 @@
15
  <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Brimbloom">
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}catch(e){}</script>
18
- <script type="application/ld+json">{"@context":"https://schema.org","@type":"VideoGame","name":"Brimbloom","url":"https://cybermaxtools.com/games/brimbloom/","image":"https://cybermaxtools.com/games/brimbloom/og.png","description":"A free one-tap timing game: stop the growing flower inside the ring. Same blooms for everyone each day.","genre":["Arcade","Casual","Timing"],"gamePlatform":["Web browser","Android","iOS"],"applicationCategory":"Game","operatingSystem":"Any","playMode":"SinglePlayer","publisher":{"@type":"Organization","name":"CyberMax","url":"https://cybermaxtools.com/"},"offers":[{"@type":"Offer","price":"0","priceCurrency":"USD","description":"Free to play"},{"@type":"Offer","price":"1.99","priceCurrency":"USD","description":"Brimbloom Skin Pack (6 flowers, one-time)"},{"@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="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>
23
  <main>
24
  <section class="game">
25
- <div class="headline"><h1>Brimbloom <span id="num" class="num"></span> <span class="pro-badge">PASS</span></h1>
26
- <p class="muted tag">Tap to stop the bloom inside the ring. One miss ends the run. Same garden for everyone today.</p></div>
27
  <div class="chips" id="modes" role="group" aria-label="Mode"></div>
 
 
 
28
  <div class="stage"><canvas id="c" width="720" height="720" aria-label="Brimbloom game. Tap, click or press Space to stop the bloom." tabindex="0"></canvas></div>
29
- <div class="row under"><span class="muted small">Today's best <b id="best">0</b> · tries <b id="tries">0</b></span><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn ghost sm" id="skins-btn" type="button">🌸 Skin Pack $1.99</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
30
  <section class="panel over" id="over" hidden aria-live="polite">
31
  <h2 id="over-title"></h2>
 
32
  <p id="over-line" class="muted"></p>
33
  <p id="over-grid" class="grid" aria-label="Your run"></p>
34
- <div class="row"><button class="btn" id="share" type="button">Share score</button><a class="btn ghost sm" id="sx" target="_blank" rel="noopener">X</a><a class="btn ghost sm" id="sb" target="_blank" rel="noopener">Bluesky</a><a class="btn ghost sm" id="sw" target="_blank" rel="noopener">WhatsApp</a><button class="btn ghost sm" id="card" type="button">Save card</button></div>
 
35
  <div class="nudge"><span>New look? The <b>Skin Pack</b> adds 6 flowers with their own ponds and petals, $1.99 once.</span><button class="btn ghost sm" data-skins="over" type="button">See the skins</button></div>
36
  </section>
37
  <div class="row skins" id="skins" aria-label="Flower skin"></div>
38
  </section>
39
 
40
- <section class="seo">
 
 
41
  <h2>How to play Brimbloom</h2>
42
- <p>A flower blooms outward from the middle of the pond. <b>Tap anywhere (or press Space) to stop it.</b> If the tips of the petals stop inside the ring (the brim), the bloom counts; stop in the brighter middle half of the ring for a <b>perfect</b>. Stop short, or let it grow past the ring, and the run is over. Each bloom is faster and its ring thinner; later blooms surge and ease, and some rings breathe in and out.</p>
43
- <h3>One garden a day</h3>
44
- <p>Everyone gets the same blooms each UTC day, so scores are directly comparable. Your best run is shared as a grid: 🟩 perfect, 🟨 good, 🟥 the bloom that ended it. Practice gives a fresh random garden every try.</p>
45
- <h3>Free, with optional extras</h3>
46
- <p>Brimbloom is free to play, including the daily garden and unlimited practice. The <b>Brimbloom Skin Pack</b> ($1.99 once) adds 6 flowers (Sakura, Lotus, Sunflower, Prism, Frost, Ember), each with its own pond and petal shape; skins are cosmetic only. The <b>CyberMax Fun Pass</b> ($2.99/month, $19/year or $29.99 for life) unlocks every past daily garden and the Night and Linen themes, in every CyberMax game.</p>
47
- <h3>FAQ</h3>
48
- <p><b>Is it fair?</b> The game runs on fixed 60 Hz ticks, and every bloom is checked so its ring opens at least 0.3 s in and stays open for at least 4 frames. <b>Does it work offline?</b> Yes, after the first visit; install it from your browser menu or the Add to Home Screen button. <b>Do skins change the game?</b> No, they only change how it looks.</p>
49
- <h2>More free games from CyberMax</h2>
50
- <div class="more"><a href="https://cybermaxtools.com/games/latchkite/"><b>Latchkite</b><span>Let go at the right moment, catch the next peg</span></a><a href="https://cybermaxtools.com/games/whirlane/"><b>Whirlane</b><span>One-tap orbit dodger with a daily leaderboard</span></a><a href="https://cybermaxtools.com/games/pathmoor/"><b>Pathmoor</b><span>Daily path puzzle: light every stone</span></a></div>
 
 
 
 
 
 
 
 
 
51
  </section>
52
  </main>
53
  <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>
54
  </div>
55
  <script type="module" src="game.js"></script>
56
- <!-- 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>Brimbloom: Free One-Tap Timing Game Online, Daily Challenge</title>
4
+ <meta name="description" content="Play Brimbloom free: a one-tap timing game with 30 levels and a daily garden. Stop the bloom on the brim of the ring, earn stars, beat a friend&#x27;s score.">
5
  <link rel="canonical" href="https://cybermaxtools.com/games/brimbloom/">
6
  <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
+ <meta property="og:title" content="Brimbloom: Free One-Tap Timing Game Online">
8
+ <meta property="og:description" content="Play Brimbloom free: a one-tap timing game with 30 levels and a daily garden. Stop the bloom on the brim of the ring, earn stars, beat a friend&#x27;s score.">
9
  <meta property="og:url" content="https://cybermaxtools.com/games/brimbloom/">
10
  <meta property="og:image" content="https://cybermaxtools.com/games/brimbloom/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/brimbloom/og.png">
 
15
  <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Brimbloom">
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}catch(e){}</script>
18
+ <meta name="twitter:title" content="Brimbloom: Free One-Tap Timing Game Online">
19
+ <meta name="twitter:description" content="Play Brimbloom free: a one-tap timing game with 30 levels and a daily garden. Stop the bloom on the brim of the ring, earn stars, beat a friend&#x27;s score.">
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/brimbloom/#game", "name": "Brimbloom", "alternateName": "Timing game online free", "url": "https://cybermaxtools.com/games/brimbloom/", "image": "https://cybermaxtools.com/games/brimbloom/og.png", "description": "Brimbloom is a free one-tap timing game for your browser. A flower blooms outward from the middle of the pond; tap to stop it with the petal tips inside the ring, in the brighter middle for a perfect. 30 levels ask you to bloom a set number of flowers before your hearts run out, adding surging blooms, breathing rings, two brims, fading rings and thorn rings, with a one-heart challenge level every 10. Earn up to 3 stars, play the same daily garden as everyone else, keep a streak and send a friend a link to beat your score.", "genre": ["Arcade", "Casual", "Timing", "One-tap"], "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-03", "keywords": "Timing game online free, free one-tap timing game, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/brimbloom/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/brimbloom/#faq", "mainEntity": [{"@type": "Question", "name": "Is Brimbloom free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. Levels 1-20, the daily garden and practice are free; levels 21-30 come with the CyberMax Fun Pass and skins are cosmetic extras."}}, {"@type": "Question", "name": "How many levels are there?", "acceptedAnswer": {"@type": "Answer", "text": "30, with a new twist about every 5 levels and a one-heart challenge level every 10."}}, {"@type": "Question", "name": "How do I get 3 stars?", "acceptedAnswer": {"@type": "Answer", "text": "Stop most flowers in the brighter middle of the ring: a perfect is worth 2 points, a good stop 1."}}, {"@type": "Question", "name": "Can I challenge a friend?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. After a run, tap Challenge a friend to send a link to the same level or daily garden with your score."}}, {"@type": "Question", "name": "Can I play offline?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, after the first visit, and you can install it like an app."}}, {"@type": "Question", "name": "Why is everyone's game the same today?", "acceptedAnswer": {"@type": "Answer", "text": "Each UTC day has one seed, so daily scores are directly comparable with friends."}}]}, {"@type": "VideoObject", "name": "Brimbloom demo: Stop the bloom on the brim", "description": "A 16-second screen recording of the live Brimbloom page: One tap stops the bloom; Inside the ring scores, centre is perfect; Share your grid, retry in one tap. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/brimbloom/poster.jpg"], "uploadDate": "2026-10-03T15:52:14+00:00", "duration": "PT16S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/brimbloom/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/brimbloom/", "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/brimbloom/#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="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="game">
28
+ <div class="headline"><h1>Brimbloom <span id="num" class="num"></span> <span class="pro-badge">PASS</span> <small class="h1kw">Free one-tap timing game</small></h1>
29
+ <p class="muted tag">Tap to stop the bloom inside the ring. 30 levels, plus one daily garden for everyone.</p></div>
30
  <div class="chips" id="modes" role="group" aria-label="Mode"></div>
31
+ <div class="lv-banner" id="challenge" hidden></div>
32
+ <section class="panel lvmap" id="levels" hidden aria-label="Levels"><div id="lvmap"></div></section>
33
+ <p class="lv-goal" id="goal"></p>
34
  <div class="stage"><canvas id="c" width="720" height="720" aria-label="Brimbloom game. Tap, click or press Space to stop the bloom." tabindex="0"></canvas></div>
35
+ <div class="row under"><span class="muted small" id="stat"></span><button class="btn ghost sm" id="install" type="button" hidden>📲 Add to Home Screen</button><button class="btn ghost sm" id="skins-btn" type="button">🌸 Skin Pack $1.99</button><button class="btn pass sm" id="pass-btn" type="button">✨ Fun Pass</button></div>
36
  <section class="panel over" id="over" hidden aria-live="polite">
37
  <h2 id="over-title"></h2>
38
+ <div class="lv-stars" id="win-stars" hidden></div>
39
  <p id="over-line" class="muted"></p>
40
  <p id="over-grid" class="grid" aria-label="Your run"></p>
41
+ <div class="row"><button class="btn" id="next-lv" type="button" hidden>Next level ▶</button><button class="btn ghost" id="retry" type="button">↻ Retry (R)</button><button class="btn ghost" id="beat" type="button">🎯 Challenge a friend</button></div>
42
+ <div class="row" id="share-row"><button class="btn" id="share" type="button">Share score</button><a class="btn ghost sm" id="sx" target="_blank" rel="noopener">X</a><a class="btn ghost sm" id="sb" target="_blank" rel="noopener">Bluesky</a><a class="btn ghost sm" id="sw" target="_blank" rel="noopener">WhatsApp</a><button class="btn ghost sm" id="card" type="button">Save card</button></div>
43
  <div class="nudge"><span>New look? The <b>Skin Pack</b> adds 6 flowers with their own ponds and petals, $1.99 once.</span><button class="btn ghost sm" data-skins="over" type="button">See the skins</button></div>
44
  </section>
45
  <div class="row skins" id="skins" aria-label="Flower skin"></div>
46
  </section>
47
 
48
+ <section class="seo" id="guide">
49
+ <h2>Brimbloom: timing game online free</h2>
50
+ <p>Brimbloom is a free one-tap timing game for your browser. A flower blooms outward from the middle of the pond; tap to stop it with the petal tips inside the ring, in the brighter middle for a perfect. 30 levels ask you to bloom a set number of flowers before your hearts run out, adding surging blooms, breathing rings, two brims, fading rings and thorn rings, with a one-heart challenge level every 10. Earn up to 3 stars, play the same daily garden as everyone else, keep a streak and send a friend a link to beat your score.</p>
51
  <h2>How to play Brimbloom</h2>
52
+ <ol><li>A flower grows outward from the centre.</li><li>Tap anywhere (or press Space) to stop it.</li><li>Petal tips inside the ring score; the bright middle of the ring is a perfect.</li><li>In a level, bloom the target number of flowers before your hearts run out; a miss costs a heart.</li><li>In the daily garden, one miss ends the run. Press R to retry at once.</li></ol>
53
+ <h3>Controls</h3>
54
+ <p><b>Phone and tablet:</b> Tap anywhere to stop the bloom.</p>
55
+ <p><b>Desktop, laptop and Chromebook:</b> Space or click to stop the bloom. M mutes.</p>
56
+ <h2>Brimbloom tips and strategy</h2>
57
+ <ul><li>Watch the rhythm of each bloom for a moment before committing; surging blooms slow down near the end.</li><li>Aim for the inner edge of the bright band, since reaction delay carries you outward.</li><li>On breathing rings, tap when the ring is at its widest.</li><li>Play with sound on: the rising tone helps timing.</li></ul>
58
+ <h2>Game modes</h2>
59
+ <ul><li>30 levels: surging blooms, breathing rings, two brims, fading rings and thorn rings, plus a one-heart challenge level every 10. Levels 1-20 are free.</li><li>Daily garden: the same blooms for everyone today, with a streak and a shareable emoji grid.</li><li>Beat-my-score links: send a friend the same level or daily garden with your score.</li><li>Practice: a random garden every try. Fun Pass: levels 21-30, past dailies and extra themes; Skin Pack adds six flowers.</li></ul>
60
+ <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>
61
+ <h2>Brimbloom FAQ</h2>
62
+ <h3>Is Brimbloom free?</h3><p>Yes. Levels 1-20, the daily garden and practice are free; levels 21-30 come with the CyberMax Fun Pass and skins are cosmetic extras.</p>
63
+ <h3>How many levels are there?</h3><p>30, with a new twist about every 5 levels and a one-heart challenge level every 10.</p>
64
+ <h3>How do I get 3 stars?</h3><p>Stop most flowers in the brighter middle of the ring: a perfect is worth 2 points, a good stop 1.</p>
65
+ <h3>Can I challenge a friend?</h3><p>Yes. After a run, tap Challenge a friend to send a link to the same level or daily garden with your score.</p>
66
+ <h3>Can I play offline?</h3><p>Yes, after the first visit, and you can install it like an app.</p>
67
+ <h3>Why is everyone&#x27;s game the same today?</h3><p>Each UTC day has one seed, so daily scores are directly comparable with friends.</p>
68
+ <h2>Similar free games</h2>
69
+ <div class="more"><a href="https://cybermaxtools.com/games/latchkite/"><b>Latchkite</b><span>Let go at the right moment, catch the next peg</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/whirlane/"><b>Whirlane</b><span>Hop orbits, dodge blades, top today&#x27;s board</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
70
  </section>
71
  </main>
72
  <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>
73
  </div>
74
  <script type="module" src="game.js"></script>
75
+ </body></html>
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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
+ }
style.css CHANGED
@@ -12,3 +12,8 @@
12
  .sp-row{display:flex;gap:6px;margin:0 0 10px}.sp-dot{width:26px;height:26px;border-radius:50%;display:inline-block;border:2px solid var(--line)}
13
  .fp-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
14
  .grid{font-size:22px;letter-spacing:2px;margin:4px 0 10px;white-space:pre-line;line-height:1.3}
 
 
 
 
 
 
12
  .sp-row{display:flex;gap:6px;margin:0 0 10px}.sp-dot{width:26px;height:26px;border-radius:50%;display:inline-block;border:2px solid var(--line)}
13
  .fp-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
14
  .grid{font-size:22px;letter-spacing:2px;margin:4px 0 10px;white-space:pre-line;line-height:1.3}
15
+ .over .row{margin-top:8px}.lvmap{margin:10px 0}
16
+ .lv-goal{min-height:24px;justify-content:center;text-align:center}.new-twist{color:var(--acc);font-weight:800}
17
+ .skins{gap:6px}.skins button{width:32px;height:32px}
18
+ .lv-goal{display:block}
19
+ @media (max-width:420px){.chips .chip{padding:5px 9px;font-size:13px}}
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 = 'brimbloom';
4
- const CACHE = `cmx-${GAME}-8716f0739d`;
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", "skins.js", "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 = 'brimbloom';
4
+ const CACHE = `cmx-${GAME}-510a600419`;
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", "skins.js", "style.css"];
6
 
7
  self.addEventListener('install', (e) => {
8
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));