CyberMax-tools commited on
Commit
825a98e
·
verified ·
1 Parent(s): a18661d

Deploy brimbloom to the hub

Browse files
.gitattributes CHANGED
@@ -987,3 +987,5 @@ brand/game-assets-bundle/image-2-inside.png filter=lfs diff=lfs merge=lfs -text
987
  brand/game-assets-bundle/image-3-output.png filter=lfs diff=lfs merge=lfs -text
988
  brand/game-assets-bundle/image-4-savings.png filter=lfs diff=lfs merge=lfs -text
989
  latchkite/shot-phone.png filter=lfs diff=lfs merge=lfs -text
 
 
 
987
  brand/game-assets-bundle/image-3-output.png filter=lfs diff=lfs merge=lfs -text
988
  brand/game-assets-bundle/image-4-savings.png filter=lfs diff=lfs merge=lfs -text
989
  latchkite/shot-phone.png filter=lfs diff=lfs merge=lfs -text
990
+ brimbloom/og.png filter=lfs diff=lfs merge=lfs -text
991
+ brimbloom/shot-phone.png filter=lfs diff=lfs merge=lfs -text
brimbloom/cmx.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax Fun kit: shared browser helpers for the CyberMax games (copied into each game's web/ folder by
2
+ // scripts/games/sync_kit.py, so every game stays self-contained and sellable on its own).
3
+ // Counter beacon (game + event + channel only: no cookies, no IDs, nothing personal), daily seed, share, PWA install.
4
+ // (c) 2026 CyberMax. All rights reserved.
5
+
6
+ export const SITE = 'https://cybermaxtools.com';
7
+ export const API = 'https://toys.cybermaxtools.com';
8
+ export const EPOCH = Date.UTC(2026, 9, 2); // 2 Oct 2026 = puzzle #1 for every CyberMax daily game
9
+
10
+ /** Today's date in the player's own time zone as YYYY-MM-DD (everyone gets a new puzzle at their local midnight). */
11
+ export function today(d = new Date()) {
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
13
+ }
14
+ /** Puzzle number for a YYYY-MM-DD date (#1 = 2026-10-02). */
15
+ export function dayNumber(iso) {
16
+ const [y, m, d] = iso.split('-').map(Number);
17
+ return Math.round((Date.UTC(y, m - 1, d) - EPOCH) / 864e5) + 1;
18
+ }
19
+ export function dateOf(n) {
20
+ const t = new Date(EPOCH + (n - 1) * 864e5);
21
+ return t.toISOString().slice(0, 10);
22
+ }
23
+
24
+ /** 32-bit string hash (FNV-1a) and a small fast PRNG (mulberry32): the same seed gives the same game everywhere. */
25
+ export function hash(str) {
26
+ let h = 0x811c9dc5;
27
+ for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
28
+ return h >>> 0;
29
+ }
30
+ export function rng(seed) {
31
+ let a = typeof seed === 'string' ? hash(seed) : seed >>> 0;
32
+ return function () {
33
+ a = (a + 0x6d2b79f5) >>> 0;
34
+ let t = a;
35
+ t = Math.imul(t ^ (t >>> 15), t | 1);
36
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
37
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
38
+ };
39
+ }
40
+
41
+ /** Count one event (play, win, share, install, pro, cta). Never breaks the page; skipped on localhost and in tests. */
42
+ export function count(game, ev, ch = '') {
43
+ try {
44
+ if (typeof location === 'undefined' || /^(localhost|127\.0\.0\.1)$/.test(location.hostname) || navigator.webdriver) return;
45
+ const u = `${API}/e?t=${encodeURIComponent(game)}&a=${encodeURIComponent(ev)}&c=${encodeURIComponent(String(ch).toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40))}`;
46
+ if (navigator.sendBeacon && navigator.sendBeacon(u)) return;
47
+ fetch(u, { method: 'POST', keepalive: true, mode: 'no-cors' }).catch(() => {});
48
+ } catch { /* never break the game */ }
49
+ }
50
+
51
+ export function store(key, val) {
52
+ try {
53
+ if (val === undefined) return JSON.parse(localStorage.getItem(key) || 'null');
54
+ localStorage.setItem(key, JSON.stringify(val));
55
+ } catch { /* private mode */ }
56
+ return val;
57
+ }
58
+
59
+ export function toast(msg, ms = 2400) {
60
+ let t = document.getElementById('toast');
61
+ if (!t) { t = document.createElement('div'); t.id = 'toast'; t.setAttribute('role', 'status'); document.body.appendChild(t); }
62
+ t.textContent = msg;
63
+ t.classList.add('on');
64
+ clearTimeout(t._h);
65
+ t._h = setTimeout(() => t.classList.remove('on'), ms);
66
+ }
67
+
68
+ /** The link people share: the canonical game page with UTM tags the site view counter reads. */
69
+ export function shareLink(path, channel, game) {
70
+ const u = new URL(SITE + path);
71
+ u.searchParams.set('utm_source', channel);
72
+ u.searchParams.set('utm_medium', 'share');
73
+ u.searchParams.set('utm_campaign', `game-${game}`);
74
+ return u.toString();
75
+ }
76
+
77
+ /**
78
+ * Share a result: native share sheet on phones (text + optional PNG card), else copy to clipboard.
79
+ * text = Wordle-style text block; path = the game's path on the CyberMax site; blob = optional PNG card.
80
+ */
81
+ export async function share(game, text, path, blob, filename) {
82
+ const url = shareLink(path, 'share', game);
83
+ const full = `${text}\n${url}`;
84
+ if (navigator.share) {
85
+ try {
86
+ const file = blob ? new File([blob], filename || `${game}.png`, { type: 'image/png' }) : null;
87
+ const data = file && navigator.canShare && navigator.canShare({ files: [file] }) ? { files: [file], text: full } : { text: full };
88
+ await navigator.share(data);
89
+ count(game, 'share', 'native');
90
+ return 'shared';
91
+ } catch (e) {
92
+ if (e && e.name === 'AbortError') return 'cancelled';
93
+ }
94
+ }
95
+ try { await navigator.clipboard.writeText(full); toast('Result copied. Paste it anywhere.'); } catch { prompt('Copy your result:', full); }
96
+ count(game, 'share', 'copy');
97
+ return 'copied';
98
+ }
99
+
100
+ export function intents(game, text, path) {
101
+ const u = (ch) => shareLink(path, ch, game);
102
+ return {
103
+ x: `https://x.com/intent/post?text=${encodeURIComponent(text)}&url=${encodeURIComponent(u('x'))}`,
104
+ bsky: `https://bsky.app/intent/compose?text=${encodeURIComponent(`${text}\n${u('bsky')}`)}`,
105
+ whatsapp: `https://wa.me/?text=${encodeURIComponent(`${text}\n${u('whatsapp')}`)}`,
106
+ };
107
+ }
108
+
109
+ export function download(blob, filename) {
110
+ const a = document.createElement('a');
111
+ a.href = URL.createObjectURL(blob);
112
+ a.download = filename;
113
+ document.body.appendChild(a);
114
+ a.click();
115
+ setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
116
+ }
117
+
118
+ /**
119
+ * Installable app: registers the service worker (offline play) and shows an "Add to Home Screen" button.
120
+ * Chrome/Edge/Android: the real install prompt. iPhone/iPad Safari: a short Share → Add to Home Screen hint.
121
+ * The button only appears after the first finished round (offer(): never on first load) and never once installed.
122
+ */
123
+ export function pwa(game, btn) {
124
+ if ('serviceWorker' in navigator && (location.protocol === 'https:' || location.hostname === 'localhost')) {
125
+ navigator.serviceWorker.register('sw.js').catch(() => {});
126
+ }
127
+ const standalone = matchMedia('(display-mode: standalone)').matches || navigator.standalone;
128
+ if (standalone) { count(game, 'play', 'app'); return { offer() {} }; }
129
+ let deferred = null;
130
+ let wanted = false;
131
+ const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) && !/crios|fxios/i.test(navigator.userAgent);
132
+ window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferred = e; if (wanted && btn) btn.hidden = false; });
133
+ window.addEventListener('appinstalled', () => { count(game, 'install', 'prompt'); if (btn) btn.hidden = true; });
134
+ if (btn) {
135
+ btn.addEventListener('click', async () => {
136
+ if (deferred) {
137
+ deferred.prompt();
138
+ const r = await deferred.userChoice.catch(() => null);
139
+ if (r && r.outcome === 'accepted') btn.hidden = true;
140
+ 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
+ }
147
+ return {
148
+ offer() {
149
+ wanted = true;
150
+ if (btn && (deferred || ios) && !store(`cmx-install-no:${game}`)) btn.hidden = false;
151
+ },
152
+ };
153
+ }
154
+
155
+ /** Rounded rectangle path for canvas cards. */
156
+ export function rr(ctx, x, y, w, h, r) {
157
+ ctx.beginPath();
158
+ ctx.moveTo(x + r, y);
159
+ ctx.arcTo(x + w, y, x + w, y + h, r);
160
+ ctx.arcTo(x + w, y + h, x, y + h, r);
161
+ ctx.arcTo(x, y + h, x, y, r);
162
+ ctx.arcTo(x, y, x + w, y, r);
163
+ ctx.closePath();
164
+ }
165
+
166
+ export const canvasBlob = (canvas) => new Promise((res) => canvas.toBlob(res, 'image/png'));
167
+
168
+ /** Theme toggle shared by every page (light/dark, remembered). */
169
+ export function themeToggle(btn) {
170
+ if (!btn) return;
171
+ btn.addEventListener('click', () => {
172
+ const r = document.documentElement;
173
+ const c = r.dataset.theme || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
174
+ const n = c === 'dark' ? 'light' : 'dark';
175
+ r.dataset.theme = n;
176
+ store('cm-theme-v', n);
177
+ try { localStorage.setItem('cm-theme', n); } catch { /* private mode */ }
178
+ });
179
+ }
brimbloom/core.js ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Brimbloom core: deterministic simulation (no DOM). A flower blooms outward; one tap stops it. Stop inside the ring
2
+ // (the brim) to clear the bloom; dead centre of the ring is a perfect. Miss once and the run ends. Blooms get faster,
3
+ // rings thinner, and later blooms pulse or their ring breathes. Same blooms for everyone each UTC day (seed = date).
4
+ // Fixed 60 Hz ticks. (c) 2026 CyberMax.
5
+
6
+ export const HZ = 60;
7
+ export const GAP = 24; // ticks between blooms (the result flash)
8
+ export const LIMIT = 1.06; // a bloom that grows past the frame is a miss
9
+
10
+ export function rng(seed) {
11
+ let h = 0x811c9dc5;
12
+ for (let i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = Math.imul(h, 0x01000193); }
13
+ let a = h >>> 0;
14
+ return () => {
15
+ a = (a + 0x6d2b79f5) >>> 0;
16
+ let t = a;
17
+ t = Math.imul(t ^ (t >>> 15), t | 1);
18
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
19
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
20
+ };
21
+ }
22
+
23
+ /** Bloom i of a seed: ring centre c and width w (radius 1 = the frame), growth speed s (radius/second),
24
+ * pulse amplitude p (the bloom surges and eases), breathe b (the ring itself drifts in and out), petals n. */
25
+ export function bloom(seed, i) {
26
+ const r = rng(`brimbloom:${seed}:${i}`);
27
+ const w = Math.max(0.075, 0.17 - i * 0.0065 - r() * 0.02);
28
+ return {
29
+ c: 0.46 + r() * 0.4, w,
30
+ s: Math.min(0.95, 0.42 + i * 0.04 + r() * 0.1),
31
+ p: i < 4 ? 0 : Math.min(0.5, 0.15 + r() * 0.35),
32
+ b: i < 7 || r() < 0.4 ? 0 : 0.02 + r() * 0.025,
33
+ n: 5 + Math.floor(r() * 4), hue: Math.floor(r() * 360),
34
+ };
35
+ }
36
+
37
+ /** Radius after t ticks. With a pulse the bloom surges and eases but never shrinks (p <= 0.5 keeps it opening). */
38
+ export function radius(b, t) {
39
+ const x = t / HZ;
40
+ return b.s * (x + b.p * Math.sin(x * 4.4) / 4.4);
41
+ }
42
+ /** The ring centre after t ticks (it breathes on later blooms). */
43
+ export const ringC = (b, t) => b.c + (b.b ? b.b * Math.sin(t / HZ * 2.2) : 0);
44
+
45
+ /** Judge a stop at radius rr against the ring: 'perfect' (middle half of the band), 'good', or 'miss'. */
46
+ export function judge(b, rr, t) {
47
+ const d = Math.abs(rr - ringC(b, t));
48
+ return d <= b.w * 0.25 ? 'perfect' : d <= b.w / 2 ? 'good' : 'miss';
49
+ }
50
+
51
+ /** A run: step(tap) once per tick; returns 'perfect' | 'good' | 'miss' | ''. */
52
+ export function makeSim(seed) {
53
+ const s = {
54
+ seed, i: 0, t: 0, wait: 0, cur: bloom(seed, 0), r: 0, marks: [], score: 0, perfect: 0, dead: false, last: '',
55
+ step(tap) {
56
+ if (this.dead) return '';
57
+ if (this.wait > 0) { if (--this.wait === 0) { this.i++; this.cur = bloom(this.seed, this.i); this.t = 0; this.r = 0; } return ''; }
58
+ this.t++;
59
+ const nr = radius(this.cur, this.t);
60
+ this.r = nr > this.r ? nr : this.r;
61
+ let res = '';
62
+ if (tap) res = judge(this.cur, this.r, this.t);
63
+ else if (this.r > LIMIT) res = 'miss';
64
+ if (!res) return '';
65
+ this.marks.push(res); this.last = res;
66
+ if (res === 'miss') { this.dead = true; return res; }
67
+ this.score++; if (res === 'perfect') this.perfect++;
68
+ this.wait = GAP;
69
+ return res;
70
+ },
71
+ };
72
+ return s;
73
+ }
74
+
75
+ /** Emoji row for the share text: 🟩 perfect, 🟨 good, 🟥 the miss that ended the run (rows of 10). */
76
+ export function grid(marks) {
77
+ const e = marks.map((m) => (m === 'perfect' ? '🟩' : m === 'good' ? '🟨' : '🟥'));
78
+ const rows = [];
79
+ for (let i = 0; i < e.length; i += 10) rows.push(e.slice(i, i + 10).join(''));
80
+ return rows.join('\n');
81
+ }
brimbloom/fun.css ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* CyberMax Fun kit: shared look for CyberMax games (copied into each game). Mobile first, light + dark. (c) 2026 CyberMax. */
2
+ :root{--bg:#F6F3EC;--card:#fff;--ink:#11141B;--muted:#565C6B;--line:#E2DCD0;--chip:#ECE6DA;--acc:#FF6A3D;--acc-ink:#11141B;--ok:#0B7F5E;--bad:#C2361B;--shadow:0 1px 2px rgba(17,20,27,.06),0 10px 30px rgba(17,20,27,.08);color-scheme:light}
3
+ @media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}}
4
+ :root[data-theme=dark]{--bg:#0F1218;--card:#181C25;--ink:#EDEAE3;--muted:#A6ACB9;--line:#2A303C;--chip:#222734;--ok:#2FD3A0;--bad:#FF7A5C;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px rgba(0,0,0,.3);color-scheme:dark}
5
+ *{box-sizing:border-box}[hidden]{display:none!important}html{-webkit-text-size-adjust:100%}
6
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;overflow-x:hidden}
7
+ a{color:inherit}img,canvas{max-width:100%}
8
+ .wrap{max-width:760px;margin:0 auto;padding:0 16px}
9
+ .top{display:flex;align-items:center;gap:6px;padding:12px 0}
10
+ .top .brand{display:flex;align-items:center;gap:8px;font-weight:800;text-decoration:none;font-size:18px;margin-right:auto}.top .brand svg{width:28px;height:28px}
11
+ .top nav a,.top .icon{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:10px;font-size:15px;background:none;border:0;font-family:inherit;cursor:pointer;min-height:40px}
12
+ .top nav a:hover,.top .icon:hover{color:var(--ink);background:var(--chip)}
13
+ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:10px 18px;border-radius:12px;border:0;background:var(--acc);color:var(--acc-ink);font:inherit;font-weight:800;font-size:16px;cursor:pointer;text-decoration:none;touch-action:manipulation}
14
+ .btn:hover{filter:brightness(1.06)}.btn.ghost{background:transparent;color:var(--ink);border:1.5px solid var(--line)}.btn.sm{min-height:40px;padding:6px 14px;font-size:15px}
15
+ .btn.pass{background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B}
16
+ .row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
17
+ .chips{display:flex;gap:6px;flex-wrap:wrap}
18
+ .chip{border:1.5px solid var(--line);background:var(--card);color:var(--ink);border-radius:999px;padding:6px 12px;font:inherit;font-size:14px;font-weight:700;cursor:pointer;min-height:36px}
19
+ .chip[aria-pressed=true]{background:var(--ink);color:var(--bg);border-color:var(--ink)}.chip .lock{opacity:.7;font-size:12px}
20
+ .panel{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:16px;box-shadow:var(--shadow)}
21
+ .muted{color:var(--muted)}.small{font-size:14px}
22
+ h1{font-size:clamp(26px,6vw,40px);line-height:1.08;letter-spacing:-.02em;margin:0 0 6px;text-wrap:balance}
23
+ h2{font-size:21px;letter-spacing:-.01em;margin:30px 0 8px}
24
+ .seo h3{font-size:17px;margin:18px 0 4px}.seo p,.seo li{color:var(--muted)}
25
+ .more{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;margin:12px 0}
26
+ .more a{display:block;padding:12px 14px;border:1px solid var(--line);border-radius:14px;background:var(--card);text-decoration:none}.more a b{display:block}.more a span{color:var(--muted);font-size:14px}
27
+ footer{color:var(--muted);font-size:14px;padding:26px 0 40px;border-top:1px solid var(--line);margin-top:36px}
28
+ #toast{position:fixed;left:50%;bottom:22px;transform:translate(-50%,40px);opacity:0;background:var(--ink);color:var(--bg);padding:12px 16px;border-radius:12px;font-weight:700;transition:.25s;z-index:99;max-width:calc(100% - 32px);text-align:center;pointer-events:none}
29
+ #toast.on{transform:translate(-50%,0);opacity:1}
30
+ .nudge{display:flex;gap:10px;align-items:center;justify-content:space-between;flex-wrap:wrap;border:1.5px dashed var(--line);border-radius:14px;padding:10px 12px;margin-top:12px;font-size:15px}
31
+ html.cmx-pro .nudge{display:none}
32
+ .pro-badge{display:none;font-size:12px;font-weight:800;background:linear-gradient(135deg,#FFB547,#FF6A3D);color:#11141B;border-radius:999px;padding:2px 8px;vertical-align:middle}
33
+ html.cmx-pro .pro-badge{display:inline-block}
34
+ dialog.funpass{border:0;padding:0;border-radius:20px;max-width:min(520px,calc(100% - 24px));width:100%;background:var(--card);color:var(--ink);box-shadow:0 20px 60px rgba(0,0,0,.35)}
35
+ dialog.funpass::backdrop{background:rgba(10,12,18,.55)}
36
+ .fp{padding:22px 20px 18px;position:relative}.fp h2{margin:2px 0 10px;font-size:24px}
37
+ .fp-x{position:absolute;right:10px;top:8px;border:0;background:none;color:var(--muted);font-size:28px;cursor:pointer;width:44px;height:44px}
38
+ .fp-kick{margin:0;color:var(--acc);font-weight:800;font-size:13px;letter-spacing:.06em;text-transform:uppercase}
39
+ .fp-perks{margin:0 0 14px;padding-left:20px}.fp-perks li{margin:2px 0}
40
+ .fp-plans{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
41
+ .fp-plan{position:relative;display:flex;flex-direction:column;gap:2px;padding:14px 10px 12px;border:1.5px solid var(--line);border-radius:14px;text-decoration:none;text-align:center;background:var(--bg)}
42
+ .fp-plan:hover{border-color:var(--acc)}.fp-plan.best{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
43
+ .fp-plan i{position:absolute;top:-10px;left:50%;transform:translateX(-50%);background:var(--acc);color:#11141B;font-style:normal;font-size:11px;font-weight:800;padding:2px 8px;border-radius:999px;white-space:nowrap}
44
+ .fp-plan b{font-size:14px}.fp-price{font-size:22px;font-weight:900}.fp-price small{font-size:12px;font-weight:600;color:var(--muted)}.fp-note{font-size:12px;color:var(--muted)}
45
+ .fp-small{font-size:13px;color:var(--muted);margin:10px 0 4px}
46
+ .fp-code{display:block;user-select:all;word-break:break-all;background:var(--chip);padding:8px;border-radius:8px;font-size:12px}
47
+ .fp details{margin-top:6px;font-size:14px}.fp-form{display:flex;gap:6px;margin-top:8px}.fp-form input{flex:1;min-width:0;font:inherit;font-size:14px;padding:8px 10px;border-radius:10px;border:1.5px solid var(--line);background:var(--bg);color:var(--ink)}
48
+ @media (max-width:420px){.fp-plans{grid-template-columns:1fr}.fp-plan{flex-direction:row;align-items:center;justify-content:space-between;text-align:left;padding:12px}.fp-plan i{left:auto;right:10px;transform:none}}
49
+ @media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
50
+ @media (max-width:560px){.chips{flex-wrap:nowrap;overflow-x:auto;scrollbar-width:none;margin:0 -16px;padding:2px 16px 4px}.chips::-webkit-scrollbar{display:none}.chip{flex:0 0 auto}}
51
+ @media (min-width:760px){dialog.funpass{max-width:720px}.fp-perks{columns:2;column-gap:28px}}
52
+ .fp-after{margin:14px 0 0;padding:12px 14px;border:1px solid var(--line, rgba(127,127,127,.35));border-radius:14px;text-align:left}.fp-after p{margin:0 0 8px}.fp-after .fp-row{display:flex;gap:8px;flex-wrap:wrap}
brimbloom/funpass.js ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>.
7
+ // Every game on the same site reads the same browser key, so one pass covers them all. The code on the receipt page
8
+ // unlocks any other device or browser. No account, no cookies, nothing personal stored. (c) 2026 CyberMax.
9
+ import { count, store, toast } from './cmx.js';
10
+
11
+ const DATA = 'https://data.cybermaxtools.com';
12
+ const KEY = 'cmx-pro:fun-pass';
13
+ const SID = /^(cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i; // Stripe order code or Gumroad licence key
14
+ const RECHECK = 3 * 864e5; // subscriptions are re-checked every 3 days; a cancelled pass turns off by itself
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
+
22
+ let state = store(KEY);
23
+ const listeners = [];
24
+ let cfg = { game: 'games', name: 'CyberMax games', perks: [] };
25
+
26
+ export const isPro = () => !!(state && state.code);
27
+ export function onChange(fn) { listeners.push(fn); fn(isPro()); }
28
+ function emit() {
29
+ document.documentElement.classList.toggle('cmx-pro', isPro());
30
+ listeners.forEach((fn) => { try { fn(isPro()); } catch { /* game hook */ } });
31
+ }
32
+
33
+ async function check(code) {
34
+ try {
35
+ const r = await fetch(`${DATA}/unlock/fun-pass?json=1&session_id=${encodeURIComponent(code)}`);
36
+ const j = await r.json();
37
+ return j && typeof j.pro === 'boolean' ? j.pro : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ export async function unlock(code) {
42
+ const ok = await check(code);
43
+ if (ok) {
44
+ state = store(KEY, { code, at: new Date().toISOString(), checked: Date.now() });
45
+ count(cfg.game, 'pro', 'unlocked');
46
+ toast('Fun Pass is on. Thank you for supporting CyberMax games!', 3500);
47
+ emit();
48
+ render();
49
+ } else if (ok === false) toast('That code is not an active Fun Pass.', 3500);
50
+ else toast('Could not reach the pass check. Try again in a minute.', 3500);
51
+ return ok;
52
+ }
53
+
54
+ async function recheck() {
55
+ if (!isPro() || Date.now() - (state.checked || 0) < RECHECK) return;
56
+ const ok = await check(state.code);
57
+ if (ok === false) { state = null; try { localStorage.removeItem(KEY); } catch { /* */ } emit(); render(); toast('Your Fun Pass has ended. Everything free is still here.', 4000); }
58
+ else if (ok) state = store(KEY, { ...state, checked: Date.now() });
59
+ }
60
+
61
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
62
+ const buyUrl = (plan) => `${DATA}/buy/st-fun-pass-${plan}?s=game-${cfg.game}`;
63
+
64
+ function dialog() {
65
+ let d = document.getElementById('funpass');
66
+ if (!d) {
67
+ d = document.createElement('dialog');
68
+ d.id = 'funpass';
69
+ d.className = 'funpass';
70
+ document.body.appendChild(d);
71
+ d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
72
+ }
73
+ return d;
74
+ }
75
+
76
+ function render() {
77
+ const d = dialog();
78
+ const perks = [...(cfg.perks || []), ...COMMON_PERKS.filter((p) => !(cfg.perks || []).some((q) => q.split(' ')[0] === p.split(' ')[0]))].slice(0, 6);
79
+ if (isPro()) {
80
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p><h2>You're a supporter. Thank you!</h2>
81
+ <p>The pass is on in this browser for every CyberMax game: ${perks.map(esc).join(' · ')}.</p>
82
+ <p class="fp-small">Your code (paste it on another device to turn the pass on there):</p><code class="fp-code">${esc(state.code)}</code>
83
+ <p class="fp-small">Manage or cancel a subscription any time from the link in your Stripe receipt email.</p></div>`;
84
+ return;
85
+ }
86
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">CyberMax Fun Pass</p>
87
+ <h2>One pass. Every game. More to play.</h2>
88
+ <ul class="fp-perks">${perks.map((p) => `<li>${esc(p)}</li>`).join('')}</ul>
89
+ <div class="fp-plans">${PLANS.map((p) => `<a class="fp-plan${p.best ? ' best' : ''}" href="${buyUrl(p.id)}" rel="nofollow" data-plan="${p.id}">${p.best ? '<i>Best value</i>' : ''}<b>${p.label}</b><span class="fp-price">${p.price}<small>${p.per}</small></span><span class="fp-note">${p.note}</span></a>`).join('')}</div>
90
+ <p class="fp-small">Secure Stripe checkout. Works in every CyberMax game. The free daily games stay free for everyone.</p>
91
+ <details><summary>Already have a pass?</summary><form class="fp-form"><input aria-label="Pass code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details></div>`;
92
+ d.querySelector('.fp-form').addEventListener('submit', (e) => {
93
+ e.preventDefault();
94
+ const c = e.target.querySelector('input').value.trim();
95
+ if (SID.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
96
+ });
97
+ d.querySelectorAll('[data-plan]').forEach((a) => a.addEventListener('click', () => count(cfg.game, 'cta', `pass-${a.dataset.plan}`)));
98
+ }
99
+
100
+ /** Open the pass sheet; `from` names the button that opened it (counted as cta). */
101
+ export function open(from = 'button') {
102
+ render();
103
+ const d = dialog();
104
+ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
105
+ count(cfg.game, 'cta', `pass-open-${from}`);
106
+ }
107
+
108
+ /** After a finished round (the value moment): one small inline card under the result, never a pop-up. Shows the
109
+ * pass price, the honest trust line and an optional "email me" weekly reminder (double opt-in).
110
+ * `host` is the result panel; the card is added once and removed for pass holders. */
111
+ export function afterWin(host, from = 'win') {
112
+ if (!host) return;
113
+ if (isPro()) { host.querySelector('.fp-after')?.remove(); return; }
114
+ let el = host.querySelector('.fp-after');
115
+ if (!el) { el = document.createElement('div'); el.className = 'fp-after'; host.appendChild(el); }
116
+ const best = PLANS.find((p) => p.best) || PLANS[0];
117
+ el.innerHTML = `<p><b>Want more today?</b> ${esc(cfg.extra || 'The Fun Pass unlocks extra packs, the full archive and themes in every CyberMax game.')}</p>
118
+ <p class="fp-row"><a class="btn sm" href="${buyUrl(best.id)}-after" rel="nofollow" data-after>Get the Fun Pass: ${esc(best.price)}${esc(best.per)}</a> <button class="btn sm ghost" type="button" data-plans>See all plans</button></p>
119
+ <p class="fp-small">Secure Stripe checkout · the pass turns on as soon as you pay · cancel any time from your receipt email · 14-day promise: if it doesn't work as described, we fix it or refund you (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). Today's free game stays free.</p>
120
+ <div class="fp-cap"></div>`;
121
+ el.querySelector('[data-after]').addEventListener('click', () => { count(cfg.game, 'cta', `pass-${best.id}-after`); kit((K) => K.event('upgrade-click', cfg.game)); });
122
+ el.querySelector('[data-plans]').addEventListener('click', () => open(`after-${from}`));
123
+ kit((K) => {
124
+ K.event('result', cfg.game); K.event('upgrade-view', cfg.game);
125
+ K.capture(el.querySelector('.fp-cap'), { list: `weekly-${cfg.game}`, src: `game-${cfg.game}`, label: cfg.remind || 'Email me a weekly reminder with new puzzles (free)', button: 'Remind me' });
126
+ });
127
+ }
128
+
129
+ // Convert kit (funnel beacons + email capture) from the CyberMax site; optional, the game works without it.
130
+ let kitQ = null;
131
+ function kit(fn) {
132
+ if (window.CMXConvert) return fn(window.CMXConvert);
133
+ if (kitQ) return kitQ.push(fn);
134
+ kitQ = [fn];
135
+ const sc = document.createElement('script');
136
+ sc.src = 'https://cybermaxtools.com/store/convert.js'; sc.async = true;
137
+ sc.onload = () => kitQ.splice(0).forEach((f) => { try { f(window.CMXConvert); } catch { /* optional */ } });
138
+ document.head.appendChild(sc);
139
+ }
140
+
141
+ /** Call once per page: game id, display name and the game's own Pro perks (listed first). */
142
+ export function init(options) {
143
+ cfg = { ...cfg, ...options };
144
+ const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
145
+ if (m && SID.test(decodeURIComponent(m[1]))) {
146
+ history.replaceState(null, '', location.pathname + location.search);
147
+ unlock(decodeURIComponent(m[1]));
148
+ } else recheck();
149
+ emit();
150
+ }
brimbloom/game.js ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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');
13
+ // tier: free | pass (CyberMax Fun Pass) | pack (Brimbloom Skin Pack, one-time). petals: list of colours or 'hue' (each bloom's own)
14
+ const SKINS = {
15
+ pond: { name: 'Pond', tier: 'free', bg: '#0E2A2A', band: 'rgba(255,255,255,.14)', sweet: 'rgba(255,255,255,.32)', edge: '#F7F1E3', petals: ['#FF8FB1', '#FFB3C8'], core: '#FFD84D', shape: 'round', text: '#F7F1E3' },
16
+ night: { name: 'Night', tier: 'pass', bg: '#0A0A1F', band: 'rgba(160,140,255,.16)', sweet: 'rgba(160,140,255,.38)', edge: '#C9C0FF', petals: ['#8F7CFF', '#C3B8FF'], core: '#FFF3B0', shape: 'pointed', text: '#ECE9FF' },
17
+ linen: { name: 'Linen', tier: 'pass', bg: '#F5EFE3', band: 'rgba(17,20,27,.08)', sweet: 'rgba(17,20,27,.18)', edge: '#11141B', petals: ['#E4572E', '#F3A712'], core: '#11141B', shape: 'round', text: '#11141B' },
18
+ sakura: { name: 'Sakura', tier: 'pack', bg: '#2B1B2E', band: 'rgba(255,214,228,.14)', sweet: 'rgba(255,214,228,.34)', edge: '#FFE4EE', petals: ['#FFC2D6', '#FFE4EE'], core: '#FF6B9A', shape: 'heart', text: '#FFE4EE' },
19
+ lotus: { name: 'Lotus', tier: 'pack', bg: '#06242E', band: 'rgba(130,240,220,.14)', sweet: 'rgba(130,240,220,.34)', edge: '#BFFFF2', petals: ['#F7A8D8', '#FFFFFF'], core: '#FFE066', shape: 'pointed', text: '#E6FFFA' },
20
+ sunflower: { name: 'Sunflower', tier: 'pack', bg: '#1E3A1A', band: 'rgba(255,230,120,.15)', sweet: 'rgba(255,230,120,.35)', edge: '#FFF2B3', petals: ['#FFC300', '#FFD84D'], core: '#5A3A1A', shape: 'pointed', text: '#FFF8DC' },
21
+ prism: { name: 'Prism', tier: 'pack', bg: '#111111', band: 'rgba(255,255,255,.12)', sweet: 'rgba(255,255,255,.3)', edge: '#FFFFFF', petals: 'hue', core: '#FFFFFF', shape: 'round', text: '#FFFFFF' },
22
+ frost: { name: 'Frost', tier: 'pack', bg: '#0B1D33', band: 'rgba(180,225,255,.15)', sweet: 'rgba(180,225,255,.36)', edge: '#E3F4FF', petals: ['#9AD8FF', '#E3F4FF'], core: '#FFFFFF', shape: 'star', text: '#E3F4FF' },
23
+ ember: { name: 'Ember', tier: 'pack', bg: '#1A0A05', band: 'rgba(255,140,60,.15)', sweet: 'rgba(255,140,60,.36)', edge: '#FFD0A8', petals: ['#FF5A1F', '#FFB03B'], core: '#FFE9A8', shape: 'star', text: '#FFE9D6' },
24
+ };
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');
69
+ b.className = 'chip'; b.type = 'button'; b.setAttribute('aria-pressed', String(it.on));
70
+ b.innerHTML = `${it.t}${it.lock ? ' <span class="lock">🔒</span>' : ''}`;
71
+ b.onclick = it.go; el.appendChild(b);
72
+ }
73
+ }
74
+ function archive() {
75
+ if (!pass.isPro()) { pass.open('archive'); return; }
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) {
120
+ requestAnimationFrame(frame);
121
+ if (state === 'run') {
122
+ acc += Math.min(0.25, (now - last) / 1000); last = 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;
138
+ function petal(ctx, len, wid, shape) {
139
+ ctx.beginPath();
140
+ if (shape === 'pointed') { ctx.moveTo(0, 0); ctx.quadraticCurveTo(wid, -len * 0.5, 0, -len); ctx.quadraticCurveTo(-wid, -len * 0.5, 0, 0); }
141
+ else if (shape === 'heart') { ctx.moveTo(0, 0); ctx.bezierCurveTo(wid * 1.3, -len * 0.4, wid * 0.8, -len * 1.05, 0, -len * 0.82); ctx.bezierCurveTo(-wid * 0.8, -len * 1.05, -wid * 1.3, -len * 0.4, 0, 0); }
142
+ else if (shape === 'star') { ctx.moveTo(0, 0); ctx.lineTo(wid * 0.55, -len * 0.45); ctx.lineTo(0, -len); ctx.lineTo(-wid * 0.55, -len * 0.45); ctx.closePath(); }
143
+ else ctx.ellipse(0, -len / 2, wid * 0.7, len / 2, 0, 0, TAU);
144
+ ctx.fill();
145
+ }
146
+ function flower(ctx, x, y, R, n, S, hue, spinA) {
147
+ if (R <= 0.5) return;
148
+ const cols = S.petals === 'hue' ? [`hsl(${hue},90%,62%)`, `hsl(${(hue + 40) % 360},90%,72%)`] : S.petals;
149
+ ctx.save(); ctx.translate(x, y); ctx.rotate(spinA);
150
+ for (let layer = 0; layer < 2; layer++) {
151
+ ctx.fillStyle = cols[layer];
152
+ const L = R * (layer ? 0.72 : 1), Wd = Math.max(4, R * (layer ? 0.3 : 0.42) * 6 / n);
153
+ for (let k = 0; k < n; k++) { ctx.save(); ctx.rotate(k * TAU / n + layer * Math.PI / n); petal(ctx, L, Wd, S.shape); ctx.restore(); }
154
+ }
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');
217
+ c.width = 1080; c.height = 1350;
218
+ const x = c.getContext('2d');
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) => {
247
+ const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass);
248
+ const k = e.target.closest('[data-skins]'); if (k) skins.open(k.dataset.skins);
249
+ });
250
+ document.addEventListener('visibilitychange', () => { if (document.hidden && state === 'run') last = performance.now(); });
251
+ function renderSkins() {
252
+ const el = $('skins'); el.innerHTML = '';
253
+ for (const [id, s] of Object.entries(SKINS)) {
254
+ const b = document.createElement('button');
255
+ const lock = !canUse(s);
256
+ b.type = 'button'; b.title = `${s.name}${s.tier === 'pass' ? ' (Fun Pass)' : s.tier === 'pack' ? ' (Skin Pack)' : ''}`; b.setAttribute('aria-label', b.title);
257
+ b.style.background = s.petals === 'hue' ? 'conic-gradient(red,yellow,lime,cyan,blue,magenta,red)' : `linear-gradient(135deg, ${s.bg} 50%, ${s.petals[0]} 50%)`;
258
+ b.setAttribute('aria-pressed', String(id === skin));
259
+ if (lock) b.classList.add('locked');
260
+ b.onclick = () => {
261
+ if (lock) { if (s.tier === 'pass') pass.open('theme'); else skins.open('dot'); return; }
262
+ skin = id; store('brimbloom:skin', id); renderSkins();
263
+ };
264
+ el.appendChild(b);
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'); }
277
+ while (s.wait > 0) s.step(false);
278
+ for (let i = 0; i < 28; i++) s.step(false);
279
+ sim = s; state = 'shot';
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);
brimbloom/icon-192.png ADDED
brimbloom/icon-512.png ADDED
brimbloom/icon-maskable.png ADDED
brimbloom/icon.svg ADDED
brimbloom/index.html ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">
12
+ <meta name="theme-color" content="#0E2A2A">
13
+ <link rel="manifest" href="manifest.webmanifest">
14
+ <link rel="icon" href="icon.svg" type="image/svg+xml"><link rel="apple-touch-icon" href="icon-192.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
+ <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
+ </body></html>
brimbloom/manifest.webmanifest ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "Brimbloom: One-tap timing: stop the bloom on the brim",
3
+ "short_name": "Brimbloom",
4
+ "description": "One-tap timing: stop the bloom on the brim. Free daily game by CyberMax, works offline.",
5
+ "id": "index.html",
6
+ "start_url": "index.html?utm_source=pwa-hf",
7
+ "scope": "./",
8
+ "display": "standalone",
9
+ "orientation": "portrait",
10
+ "background_color": "#0E2A2A",
11
+ "theme_color": "#0E2A2A",
12
+ "categories": [
13
+ "games",
14
+ "entertainment"
15
+ ],
16
+ "icons": [
17
+ {
18
+ "src": "icon-192.png",
19
+ "sizes": "192x192",
20
+ "type": "image/png"
21
+ },
22
+ {
23
+ "src": "icon-512.png",
24
+ "sizes": "512x512",
25
+ "type": "image/png"
26
+ },
27
+ {
28
+ "src": "icon-maskable.png",
29
+ "sizes": "512x512",
30
+ "type": "image/png",
31
+ "purpose": "maskable"
32
+ },
33
+ {
34
+ "src": "icon.svg",
35
+ "sizes": "any",
36
+ "type": "image/svg+xml"
37
+ }
38
+ ],
39
+ "screenshots": [
40
+ {
41
+ "src": "shot-phone.png",
42
+ "sizes": "780x1688",
43
+ "type": "image/png",
44
+ "form_factor": "narrow"
45
+ }
46
+ ]
47
+ }
brimbloom/og.png ADDED

Git LFS Details

  • SHA256: 663ea41f64a160ed16e01b054d5bf81727ef65f9ef7bc723fa1bc01892769ff2
  • Pointer size: 131 Bytes
  • Size of remote file: 119 kB
brimbloom/shot-phone.png ADDED

Git LFS Details

  • SHA256: e3c171f34458258772f526dbd9f59b7455cc15f6ce475044726dc26152a9b778
  • Pointer size: 131 Bytes
  • Size of remote file: 201 kB
brimbloom/skins.js ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax game Skin Pack: a one-time cosmetic unlock for ONE game (separate from the Fun Pass, which covers every
2
+ // game's archive + themes). Buy: counted redirect data.cybermaxtools.com/buy/st-<game>-skins-pack?s=game-<game>
3
+ // -> Stripe Payment Link; or the Gumroad listing (licence key). After checkout data.cybermaxtools.com/unlock/<game>-skins
4
+ // checks the order (or the Gumroad licence key) and returns to the game with ?unlock=skins#cmx-pro=<code>.
5
+ // capture() must run BEFORE funpass.init() so the Fun Pass never mistakes a skin-pack code for a pass code.
6
+ // Cosmetic only: skins never change the game, scores or the daily course. (c) 2026 CyberMax.
7
+ import { count, store, toast } from './cmx.js';
8
+
9
+ const DATA = 'https://data.cybermaxtools.com';
10
+ const CODE = /^(cs_(live|test)_[A-Za-z0-9]{10,200}|[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8}-[A-Z0-9]{8})$/i;
11
+ let cfg = { game: '', name: '', price: '$1.99', gumroad: '', skins: 0 };
12
+ let state = null;
13
+ const subs = [];
14
+ const key = () => `cmx-skins:${cfg.game}`;
15
+
16
+ export const owned = () => !!(state && state.code);
17
+ export function onChange(fn) { subs.push(fn); }
18
+ const emit = () => subs.forEach((f) => { try { f(owned()); } catch { /* game hook */ } });
19
+
20
+ async function check(code) {
21
+ try {
22
+ const r = await fetch(`${DATA}/unlock/${cfg.game}-skins?json=1&session_id=${encodeURIComponent(code)}`);
23
+ const j = await r.json();
24
+ return j && typeof j.pro === 'boolean' ? j.pro : null;
25
+ } catch { return null; }
26
+ }
27
+ export async function unlock(code) {
28
+ const ok = await check(code);
29
+ if (ok) {
30
+ state = store(key(), { code, at: new Date().toISOString() });
31
+ count(cfg.game, 'pro', 'skins-unlocked');
32
+ toast(`${cfg.name} Skin Pack is on. Enjoy the new looks!`, 3500);
33
+ emit(); render();
34
+ } else if (ok === false) toast('That code is not a Skin Pack order for this game.', 3500);
35
+ else toast('Could not reach the unlock check. Try again in a minute.', 3500);
36
+ return ok;
37
+ }
38
+
39
+ /** Call first: game id, display name, skin count, Gumroad URL. Reads ?unlock=skins#cmx-pro=<code> after checkout. */
40
+ export function capture(options) {
41
+ cfg = { ...cfg, ...options };
42
+ state = store(key());
43
+ const q = new URLSearchParams(location.search);
44
+ const m = /[#&]cmx-pro=([^&]+)/.exec(location.hash || '');
45
+ if (q.get('unlock') === 'skins' && m) {
46
+ const code = decodeURIComponent(m[1]);
47
+ q.delete('unlock');
48
+ history.replaceState(null, '', location.pathname + (q.toString() ? `?${q}` : ''));
49
+ if (CODE.test(code)) unlock(code);
50
+ }
51
+ }
52
+
53
+ const buyUrl = (from) => `${DATA}/buy/st-${cfg.game}-skins-pack?s=game-${cfg.game}${from ? `-${from}` : ''}`;
54
+ function dialog() {
55
+ let d = document.getElementById('skinpack');
56
+ if (!d) {
57
+ d = document.createElement('dialog'); d.id = 'skinpack'; d.className = 'funpass';
58
+ document.body.appendChild(d);
59
+ d.addEventListener('click', (e) => { if (e.target === d || e.target.closest('[data-close]')) d.close(); });
60
+ }
61
+ return d;
62
+ }
63
+ function render() {
64
+ const d = dialog();
65
+ const prev = (cfg.preview || []).map((s) => `<span class="sp-dot" style="background:${s}"></span>`).join('');
66
+ if (owned()) {
67
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${cfg.name} Skin Pack</p><h2>All ${cfg.skins} skins are yours. Thank you!</h2>
68
+ <p>Pick one from the colour dots under the game.</p><p class="fp-small">Your code (paste it on another device):</p><code class="fp-code">${state.code}</code></div>`;
69
+ return;
70
+ }
71
+ d.innerHTML = `<div class="fp"><button class="fp-x" data-close aria-label="Close">×</button><p class="fp-kick">${cfg.name} Skin Pack</p>
72
+ <h2>${cfg.skins} extra looks, ${cfg.price} once</h2><p class="sp-row">${prev}</p>
73
+ <ul class="fp-perks">${(cfg.perks || []).map((p) => `<li>${p}</li>`).join('')}<li>Cosmetic only: same course, same scores for everyone</li><li>Pay once, keep forever, on any device with your code</li></ul>
74
+ <p class="fp-row"><a class="btn pass" href="${buyUrl('')}" rel="nofollow" data-buy>Get the Skin Pack: ${cfg.price}</a>${cfg.gumroad ? ` <a class="btn ghost sm" href="${cfg.gumroad}" rel="nofollow noopener" target="_blank" data-gum>or on Gumroad</a>` : ''}</p>
75
+ <p class="fp-small">Secure Stripe checkout · the skins turn on as soon as you pay · 14-day promise: if it doesn't work as described, we fix it or refund you (<a href="https://cybermaxtools.com/terms#payments" target="_blank" rel="noopener">terms</a>). The <b>Fun Pass</b> is a separate pass for every game's archive and themes.</p>
76
+ <details><summary>Already bought it?</summary><form class="fp-form"><input aria-label="Order code" autocomplete="off" placeholder="Receipt code (cs_live_…) or Gumroad licence key"><button class="btn sm" type="submit">Turn on</button></form></details></div>`;
77
+ d.querySelector('.fp-form').addEventListener('submit', (e) => {
78
+ e.preventDefault();
79
+ const c = e.target.querySelector('input').value.trim();
80
+ if (CODE.test(c)) unlock(c); else toast('Paste the code from your receipt page (cs_live_…) or your Gumroad licence key.', 3500);
81
+ });
82
+ d.querySelector('[data-buy]').addEventListener('click', () => count(cfg.game, 'cta', 'skins-buy'));
83
+ d.querySelector('[data-gum]')?.addEventListener('click', () => count(cfg.game, 'cta', 'skins-gumroad'));
84
+ }
85
+ export function open(from = 'button') {
86
+ render();
87
+ const d = dialog();
88
+ if (d.showModal) d.showModal(); else d.setAttribute('open', '');
89
+ count(cfg.game, 'cta', `skins-open-${from}`);
90
+ }
brimbloom/style.css ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Brimbloom layout. Game colours live in game.js (SKINS). (c) 2026 CyberMax. */
2
+ .headline h1 .num{color:var(--muted);font-weight:700;font-size:.62em}
3
+ .tag{margin:0 0 12px}
4
+ .stage{display:flex;justify-content:center;margin-top:12px}
5
+ #c{width:100%;max-width:min(560px,calc(100vh - 220px));min-width:260px;aspect-ratio:1;border-radius:24px;touch-action:none;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer;box-shadow:var(--shadow)}
6
+ .under{justify-content:center;margin:12px 0}
7
+ .over{margin:12px 0}.over h2{margin:0 0 4px}
8
+ .skins{justify-content:center;margin:8px 0}
9
+ .skins button{width:36px;height:36px;border-radius:50%;border:3px solid var(--line);cursor:pointer;position:relative}
10
+ .skins button[aria-pressed=true]{border-color:var(--ink)}
11
+ .skins button.locked::after{content:"🔒";position:absolute;right:-6px;bottom:-6px;font-size:12px}
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}
brimbloom/sw.js ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
2
+ // network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
3
+ const GAME = '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()));
9
+ });
10
+ self.addEventListener('activate', (e) => {
11
+ e.waitUntil(caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith(`cmx-${GAME}-`) && k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
12
+ });
13
+ self.addEventListener('fetch', (e) => {
14
+ const u = new URL(e.request.url);
15
+ if (e.request.method !== 'GET' || u.origin !== location.origin || !u.pathname.startsWith(new URL(self.registration.scope).pathname)) return;
16
+ if (e.request.mode === 'navigate') {
17
+ e.respondWith(fetch(e.request).then((r) => { const c = r.clone(); caches.open(CACHE).then((x) => x.put(u.pathname, c)); return r; })
18
+ .catch(() => caches.match(u.pathname).then((r) => r || caches.match('index.html'))));
19
+ return;
20
+ }
21
+ e.respondWith(caches.match(e.request, { ignoreSearch: true }).then((r) => r || fetch(e.request).then((res) => {
22
+ if (res.ok) { const c = res.clone(); caches.open(CACHE).then((x) => x.put(e.request, c)); }
23
+ return res;
24
+ })));
25
+ });