CyberMax-tools commited on
Commit
b7f6190
ยท
verified ยท
1 Parent(s): e45933c

Deploy pathmoor

Browse files
Files changed (9) hide show
  1. README.md +12 -2
  2. cmx.js +3 -1
  3. core.js +138 -0
  4. funpass.js +2 -2
  5. game.js +198 -46
  6. index.html +40 -23
  7. levels.js +341 -0
  8. style.css +2 -0
  9. sw.js +2 -2
README.md CHANGED
@@ -6,6 +6,7 @@ colorTo: green
6
  sdk: static
7
  pinned: false
8
  license: other
 
9
  short_description: "Daily path puzzle: turn stones until every path glows"
10
  tags:
11
  - game
@@ -16,12 +17,21 @@ tags:
16
  - browser-game
17
  - pwa
18
  - cybermax
19
- thumbnail: https://huggingface.co/spaces/CyberMax-tools/pathmoor/resolve/main/thumbnail.png
20
  ---
21
  **Pathmoor**: Daily path puzzle: turn stones until every path glows. 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/pathmoor/?utm_source=huggingface
24
 
25
- 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
 
 
 
 
 
 
 
 
 
 
26
 
27
  More free games: https://cybermaxtools.com/games/ ยท ยฉ 2026 CyberMax. All rights reserved.
 
6
  sdk: static
7
  pinned: false
8
  license: other
9
+ thumbnail: https://cybermaxtools.com/games/pathmoor/og.png
10
  short_description: "Daily path puzzle: turn stones until every path glows"
11
  tags:
12
  - game
 
17
  - browser-game
18
  - pwa
19
  - cybermax
 
20
  ---
21
  **Pathmoor**: Daily path puzzle: turn stones until every path glows. 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/pathmoor/?utm_source=huggingface
24
 
25
+ **Watch the real gameplay demo:** https://cybermaxtools.com/media/demos/pathmoor/demo-16x9.mp4
26
+
27
+ ![Pathmoor gameplay](https://cybermaxtools.com/media/demos/pathmoor/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/pathmoor/" width="480" height="720" style="border:0;max-width:100%;aspect-ratio:2/3;height:auto" title="Pathmoor: 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
@@ -141,3 +141,141 @@ export const PACKS = [
141
  { id: 'highland', name: 'Highland 8ร—8', n: 8, levels: 100 },
142
  { id: 'summit', name: 'Summit 10ร—10', n: 10, levels: 50 },
143
  ];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  { id: 'highland', name: 'Highland 8ร—8', n: 8, levels: 100 },
142
  { id: 'summit', name: 'Summit 10ร—10', n: 10, levels: 50 },
143
  ];
144
+
145
+ // ------------------------------------------------------------------ levels (30 hand-tuned boards + twists)
146
+ // Twists, one new idea about every 5 levels: holes (shaped boards), twin stones (tap one, both turn), fog (you only
147
+ // see stones next to the light), reverse stones (turn anticlockwise) and bigger boards. Every 10th level is a
148
+ // challenge level with a tap budget: run out of taps and the board resets.
149
+ // Level boards use their own seed space, so the daily board above is unchanged.
150
+
151
+ /** n = size, mix = share of stones shuffled (early levels), holes = empty cells, twins = twin pairs, fog, rev = reverse stones, budget = par + slack (challenge). */
152
+ export const LEVELS = [
153
+ { n: 3, mix: 0.35, name: 'First light' }, { n: 4, mix: 0.45, name: 'Crossroads' }, { n: 4, mix: 0.7, name: 'Moor gate' }, { n: 5, mix: 0.6, name: 'Bracken' }, { n: 5, mix: 0.7, name: 'Lantern walk' },
154
+ { n: 5, holes: 3, name: 'Broken ground', twist: 'Holes in the moor' }, { n: 5, holes: 4, name: 'Bog pools' }, { n: 6, name: 'Long dusk' }, { n: 6, holes: 5, name: 'Peat cuts' },
155
+ { n: 6, holes: 3, budget: 6, name: 'Lantern trial', boss: true },
156
+ { n: 5, twins: 2, name: 'Twin stones', twist: 'Twin stones turn together' }, { n: 6, twins: 3, name: 'Paired path' }, { n: 6, twins: 3, holes: 3, name: 'Old wall' }, { n: 6, twins: 4, name: 'Mirror field' }, { n: 7, twins: 3, name: 'Wide moor' },
157
+ { n: 6, fog: true, name: 'Moor fog', twist: 'Fog: only stones near the light show' }, { n: 6, fog: true, twins: 2, name: 'Grey morning' }, { n: 7, fog: true, holes: 4, name: 'Mist pools' }, { n: 7, twins: 4, holes: 4, name: 'Heather maze' },
158
+ { n: 7, fog: true, twins: 3, budget: 10, name: 'Fog trial', boss: true },
159
+ { n: 6, rev: 5, name: 'Backwards stones', twist: 'Purple stones turn the other way' }, { n: 7, rev: 7, name: 'Turnabout' }, { n: 7, rev: 6, twins: 3, name: 'Crossed wires' }, { n: 8, name: 'Highland' }, { n: 8, holes: 6, fog: true, name: 'Cloud tops' },
160
+ { n: 9, name: 'Great moor', twist: 'Bigger boards' }, { n: 8, rev: 8, twins: 4, holes: 5, name: 'Tangle' }, { n: 9, fog: true, rev: 8, name: 'Night walk' }, { n: 9, rev: 9, twins: 5, holes: 6, name: 'Summit path' },
161
+ { n: 10, rev: 10, twins: 5, holes: 6, fog: true, budget: 14, name: 'Summit trial', boss: true },
162
+ ];
163
+
164
+ /** Stones reachable from the lantern (holes are walls). */
165
+ export function litB(b, cur) { return lit(b.n, b.src, cur); }
166
+
167
+ /** Solved: no loose ends and every stone that is not a hole is lit. */
168
+ export function isSolvedB(b, cur) {
169
+ if (looseEnds(b.n, cur)) return false;
170
+ const on = lit(b.n, b.src, cur);
171
+ for (let i = 0; i < on.length; i++) if (!on[i] && !b.hole[i]) return false;
172
+ return true;
173
+ }
174
+
175
+ /** The stones one tap on stone i turns (its twin too). */
176
+ export const groupOf = (b, i) => (b.twin[i] >= 0 ? [i, b.twin[i]] : [i]);
177
+ /** Turn direction of stone i: 1 clockwise, 3 (= one anticlockwise quarter) for reverse stones. */
178
+ export const dirOf = (b, i) => (b.rev[i] ? 3 : 1);
179
+
180
+ /** Apply one tap on stone i to cur (in place). k = 1 forward, 3 = back (undo / shift-tap). */
181
+ export function tapB(b, cur, i, k = 1) {
182
+ if (b.hole[i]) return false;
183
+ for (const j of groupOf(b, i)) cur[j] = turn(cur[j], dirOf(b, j) * k);
184
+ return true;
185
+ }
186
+
187
+ /** Fewest taps on the group of stone i that bring it from start back to the solution. */
188
+ function groupTaps(b, start, i) {
189
+ const g = groupOf(b, i);
190
+ for (let k = 0; k < 4; k++) if (g.every((j) => turn(start[j], dirOf(b, j) * k) === b.sol[j])) return k;
191
+ return 0;
192
+ }
193
+
194
+ /** Par for a level board: the fewest taps from the shuffle to our solution. */
195
+ export function parB(b) {
196
+ let s = 0;
197
+ const seen = new Set();
198
+ for (let i = 0; i < b.n * b.n; i++) {
199
+ if (b.hole[i] || seen.has(i)) continue;
200
+ groupOf(b, i).forEach((j) => seen.add(j));
201
+ s += groupTaps(b, b.start, i);
202
+ }
203
+ return s;
204
+ }
205
+
206
+ /** Stars: 3 = par or better, 2 = within about a third over par, 1 = solved. */
207
+ export function levelStars(taps, p) {
208
+ return taps <= p ? 3 : taps <= p + Math.ceil(p * 0.34) + 1 ? 2 : 1;
209
+ }
210
+
211
+ /** Build level `num` (1-based) from LEVELS. Deterministic: the same level is the same board for everyone. */
212
+ export function generateLevel(num) {
213
+ const L = LEVELS[num - 1];
214
+ const n = L.n, rand = rng(`pathmoor:lv:${num}`);
215
+ for (let attempt = 0; attempt < 400; attempt++) {
216
+ const hole = new Uint8Array(n * n);
217
+ const c = Math.floor((n - 1) / 2);
218
+ const src = (c + Math.floor(rand() * 2)) * n + c + Math.floor(rand() * 2);
219
+ let holes = L.holes || 0;
220
+ for (let tries = 0; holes > 0 && tries < 200; tries++) {
221
+ const i = Math.floor(rand() * n * n);
222
+ if (i === src || hole[i]) continue;
223
+ hole[i] = 1; holes--;
224
+ }
225
+ const sol = new Array(n * n).fill(0);
226
+ const inTree = new Uint8Array(n * n);
227
+ inTree[src] = 1;
228
+ let frontier = [];
229
+ const addEdges = (i) => {
230
+ const x = i % n, y = (i / n) | 0;
231
+ for (const [bt, dx, dy, back] of DIRS) {
232
+ const nx = x + dx, ny = y + dy, j = ny * n + nx;
233
+ if (nx >= 0 && ny >= 0 && nx < n && ny < n && !inTree[j] && !hole[j]) frontier.push([i, j, bt, back]);
234
+ }
235
+ };
236
+ addEdges(src);
237
+ let count = 1;
238
+ const need = n * n - (L.holes || 0);
239
+ while (frontier.length) {
240
+ const k = Math.floor(rand() * frontier.length);
241
+ const [i, j, bt, back] = frontier[k];
242
+ frontier.splice(k, 1);
243
+ if (inTree[j]) continue;
244
+ sol[i] |= bt; sol[j] |= back; inTree[j] = 1; count++;
245
+ addEdges(j);
246
+ }
247
+ if (count < need) continue; // holes cut the moor in two: re-roll
248
+ const cells = [...Array(n * n).keys()].filter((i) => !hole[i]);
249
+ const straights = cells.filter((i) => sol[i] === (N | S) || sol[i] === (E | W)).length;
250
+ if (n >= 5 && straights > cells.length * 0.4) continue;
251
+ // twins: pairs of stones (not crosses, not the lantern) that turn together
252
+ const twin = new Int16Array(n * n).fill(-1);
253
+ const pool = cells.filter((i) => i !== src && bits(sol[i]) < 4);
254
+ for (let t = 0; t < (L.twins || 0) && pool.length > 1; t++) {
255
+ const a = pool.splice(Math.floor(rand() * pool.length), 1)[0];
256
+ const bb = pool.splice(Math.floor(rand() * pool.length), 1)[0];
257
+ twin[a] = bb; twin[bb] = a;
258
+ }
259
+ const rev = new Uint8Array(n * n);
260
+ const rpool = cells.filter((i) => twin[i] < 0 && bits(sol[i]) < 4 && !(sol[i] === (N | S) || sol[i] === (E | W)));
261
+ for (let t = 0; t < (L.rev || 0) && rpool.length; t++) rev[rpool.splice(Math.floor(rand() * rpool.length), 1)[0]] = 1;
262
+ const b = { n, src, sol, hole, twin, rev, fog: !!L.fog, level: num };
263
+ // shuffle by tapping: every group gets 0-3 backward taps, so the board is always solvable
264
+ const start = sol.slice();
265
+ const done = new Set();
266
+ for (const i of cells) {
267
+ if (done.has(i)) continue;
268
+ groupOf(b, i).forEach((j) => done.add(j));
269
+ const k = rand() < (L.mix || 0.75) ? 1 + Math.floor(rand() * 3) : 0;
270
+ for (const j of groupOf(b, i)) start[j] = turn(start[j], dirOf(b, j) * 3 * k);
271
+ }
272
+ b.start = start;
273
+ if (isSolvedB(b, start)) continue;
274
+ const p = parB(b);
275
+ if (p < Math.max(3, Math.floor(cells.length * 0.35 * (L.mix || 0.75)))) continue; // too few taps needed: not a real puzzle
276
+ b.par = p;
277
+ b.budget = L.budget ? p + L.budget : 0;
278
+ return b;
279
+ }
280
+ throw new Error(`level ${num}: no board`);
281
+ }
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,5 +1,6 @@
1
- // Pathmoor: the game screen (canvas board, timer, packs, archive, share, card, themes). (c) 2026 CyberMax.
2
- import { generate, isSolved, lit, turn, turnsTo, par, shareGrid, fmtTime, PACKS, bits } from './core.js';
 
3
  import { today, dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
4
  import * as pass from './funpass.js';
5
 
@@ -19,10 +20,16 @@ const SKINS = {
19
  const todayIso = today();
20
  const todayNum = dayNumber(todayIso);
21
  let skin = SKINS[store('pathmoor:skin')] ? store('pathmoor:skin') : 'moor';
22
- let mode; // { kind: 'daily', date } | { kind: 'pack', pack, level }
23
- let board, cur, taps, undoStack, ms, started, done, key, focus = -1, anim = {}, winAt = 0, timer;
 
 
 
 
 
 
24
 
25
- pass.init({ game: GAME, name: 'Pathmoor', perks: ['250 bigger boards: Moorland 7ร—7, Highland 8ร—8, Summit 10ร—10', 'Every past daily in the archive', 'Ember, Tide, Dusk and Paper board themes'] });
26
  const install = pwa(GAME, $('install'));
27
  themeToggle($('theme'));
28
 
@@ -30,52 +37,117 @@ themeToggle($('theme'));
30
 
31
  function packOf(id) { return PACKS.find((p) => p.id === id); }
32
  function locked(m) {
 
33
  if (pass.isPro()) return false;
34
  if (m.kind === 'daily') return m.date !== todayIso;
35
  const p = packOf(m.pack);
36
  return !(p.freeLevels && m.level <= p.freeLevels);
37
  }
38
- function keyOf(m) { return m.kind === 'daily' ? `d:${m.date}` : `p:${m.pack}:${m.level}`; }
39
- function label(m) { return m.kind === 'daily' ? `#${dayNumber(m.date)}` : `${packOf(m.pack).name} ยท ${m.level}`; }
40
 
41
  function fromUrl() {
42
  const q = new URLSearchParams(location.search);
 
 
 
 
43
  const d = q.get('d');
44
  if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso) return { kind: 'daily', date: d };
45
  const p = packOf(q.get('p') || '');
46
  const l = Number(q.get('l'));
47
  if (p && p.id !== 'daily' && l >= 1 && l <= p.levels) return { kind: 'pack', pack: p.id, level: l };
 
 
48
  return { kind: 'daily', date: todayIso };
49
  }
50
 
 
 
 
 
 
 
 
 
 
 
 
51
  function load(m, push = false) {
52
- if (locked(m)) { pass.open(m.kind === 'daily' ? 'archive' : m.pack); return false; }
 
 
 
 
53
  mode = m;
54
  key = keyOf(m);
55
- const n = m.kind === 'daily' ? 6 : packOf(m.pack).n;
56
- board = generate(m.kind === 'daily' ? m.date : `${m.pack}-${m.level}`, n);
 
 
 
57
  const saved = store(`pathmoor:b:${key}`);
58
  if (saved && saved.cur && saved.cur.length === n * n) {
59
  cur = saved.cur; taps = saved.taps || {}; ms = saved.ms || 0; done = !!saved.done; undoStack = [];
60
  } else { cur = board.start.slice(); taps = {}; ms = 0; done = false; undoStack = []; }
61
  started = 0; anim = {}; focus = -1;
62
  $('num').textContent = label(m);
63
- $('par').textContent = par(board);
 
 
64
  if (push) {
65
  const u = new URL(location.href);
66
- u.search = m.kind === 'daily' ? (m.date === todayIso ? '' : `?d=${m.date}`) : `?p=${m.pack}&l=${m.level}`;
67
  window.history.replaceState(null, '', u.pathname + u.search);
68
  }
69
  renderModes();
70
  $('win').hidden = true;
71
  if (done) showWin(false);
72
  hud(); draw();
 
 
 
 
 
73
  return true;
74
  }
75
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  function renderModes() {
77
  const el = $('modes');
78
- const chips = [{ m: { kind: 'daily', date: todayIso }, t: `Today #${todayNum}`, on: mode.kind === 'daily' && mode.date === todayIso }];
 
79
  for (const p of PACKS.filter((x) => x.id !== 'daily')) {
80
  const lvl = Math.min(p.levels, (store(`pathmoor:pack:${p.id}`) || 0) + 1);
81
  const m = { kind: 'pack', pack: p.id, level: lvl };
@@ -88,7 +160,7 @@ function renderModes() {
88
  b.className = 'chip'; b.type = 'button';
89
  b.setAttribute('aria-pressed', String(!!c.on));
90
  b.innerHTML = `${c.t}${c.lock ? ' <span class="lock">๐Ÿ”’</span>' : ''}`;
91
- b.onclick = () => (c.archive ? archive() : load(c.m, true));
92
  el.appendChild(b);
93
  });
94
  }
@@ -108,31 +180,43 @@ function save() { store(`pathmoor:b:${key}`, { cur, taps, ms: elapsed(), done })
108
  function elapsed() { return ms + (started ? performance.now() - started : 0); }
109
 
110
  function tap(i, k = 1) {
111
- if (done || i < 0) return;
112
  if (!started) {
113
  started = performance.now();
114
  if (!Object.keys(taps).length && !ms) count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack);
115
  }
116
- if (bits(cur[i]) === 4) return; // a cross never changes
117
- undoStack.push([i, cur[i]]);
118
- cur[i] = turn(cur[i], k);
 
119
  taps[i] = (taps[i] || 0) + 1;
120
- anim[i] = { from: k === 1 ? -90 : 90, t0: performance.now() };
121
  if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ }
122
- if (isSolved(board.n, board.src, cur)) win();
 
 
 
123
  save(); hud(); loop();
124
  }
 
 
 
 
 
 
 
 
 
125
 
126
  function undo() {
127
  if (done || !undoStack.length) return;
128
- const [i, m] = undoStack.pop();
129
- cur[i] = m;
130
- anim[i] = { from: 90, t0: performance.now() };
131
  save(); hud(); loop();
132
  }
133
 
134
- function restart() {
135
- if (done && !confirm('Start this board again? Your result stays saved.')) return;
 
136
  cur = board.start.slice(); taps = {}; ms = 0; started = 0; done = false; undoStack = [];
137
  store(`pathmoor:b:${key}`, null);
138
  $('win').hidden = true;
@@ -144,17 +228,25 @@ function totalTaps() { return Object.values(taps).reduce((a, b) => a + b, 0); }
144
  function hud() {
145
  $('time').textContent = fmtTime(done ? ms : elapsed());
146
  $('taps').textContent = totalTaps();
147
- const on = lit(board.n, board.src, cur);
148
- $('lit').textContent = `${Math.round((on.reduce((a, b) => a + b, 0) / on.length) * 100)}%`;
 
 
149
  }
150
 
151
  function win() {
152
  ms = elapsed(); started = 0; done = true; winAt = performance.now();
153
- count(GAME, 'win', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack);
154
- if (mode.kind === 'daily') {
 
 
 
 
 
 
155
  const st = store('pathmoor:stats') || { days: {}, streak: 0, best: 0 };
156
  if (!st.days[mode.date]) {
157
- st.days[mode.date] = { ms: Math.round(ms), taps: totalTaps(), par: par(board) };
158
  if (mode.date === todayIso) {
159
  const y = dateOf(todayNum - 1);
160
  st.streak = st.days[y] ? (st.streak || 0) + 1 : 1;
@@ -165,31 +257,55 @@ function win() {
165
  } else {
166
  store(`pathmoor:pack:${mode.pack}`, Math.max(store(`pathmoor:pack:${mode.pack}`) || 0, mode.level));
167
  }
 
168
  save();
169
  setTimeout(() => showWin(true), 650);
170
  install.offer();
171
  }
172
 
173
  function shareText() {
174
- const t = totalTaps(), p = par(board);
 
175
  const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} ๐Ÿฎ` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} ๐Ÿฎ`;
176
- return `${head} ${fmtTime(ms)} ยท ${t} taps (par ${p})${t <= p ? ' โญ perfect' : ''}\n${shareGrid(board, taps)}`;
 
 
 
 
 
 
 
177
  }
178
 
179
  function showWin(scroll) {
180
  const st = store('pathmoor:stats') || {};
181
- const t = totalTaps(), p = par(board);
182
  $('win-title').textContent = t <= p ? 'Perfect! Every stone lit in par.' : 'Every stone is lit!';
 
 
 
 
 
 
 
 
 
 
183
  $('win-line').textContent = `${fmtTime(ms)} ยท ${t} taps (par ${p})` + (mode.kind === 'daily' && mode.date === todayIso && st.streak ? ` ยท ๐Ÿ”ฅ ${st.streak}-day streak` : '');
184
- $('win-grid').textContent = shareGrid(board, taps);
 
 
 
 
185
  const text = shareText();
186
- const it = intents(GAME, text, PATH);
187
  $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
188
  const pr = $('practice');
189
- if (mode.kind === 'pack') {
 
190
  const pk = packOf(mode.pack);
191
  pr.textContent = mode.level < pk.levels ? `Next board: ${pk.name} #${mode.level + 1}` : 'Pack complete! Try another pack';
192
- } else pr.textContent = 'Play a practice board';
193
  $('win').hidden = false;
194
  pass.afterWin($('win'), 'win');
195
  tick();
@@ -205,14 +321,37 @@ function tick() {
205
 
206
  // ------------------------------------------------------------------ drawing
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) {
209
  const S = SKINS[skin];
 
210
  const n = board.n, cs = size / n, pad = cs * 0.06, w = cs * 0.2;
211
  c.save();
212
  c.fillStyle = S.bg; rr(c, x0, y0, size, size, size * 0.04); c.fill();
213
  for (let i = 0; i < n * n; i++) {
214
  const x = x0 + (i % n) * cs, y = y0 + ((i / n) | 0) * cs, cx = x + cs / 2, cy = y + cs / 2;
215
- c.fillStyle = S.stone; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill();
 
 
 
 
216
  if (i === focusIdx) { c.strokeStyle = S.on; c.lineWidth = 3; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.stroke(); }
217
  let ang = 0;
218
  const a = anim[i];
@@ -244,7 +383,7 @@ let raf = 0;
244
  function draw() {
245
  const t = performance.now();
246
  ctx.clearRect(0, 0, canvas.width, canvas.height);
247
- const on = done ? new Uint8Array(cur.length).fill(1) : lit(board.n, board.src, cur);
248
  drawBoard(ctx, 0, 0, canvas.width, cur, on, t, document.activeElement === canvas ? focus : -1);
249
  if (done && t - winAt < 1200) {
250
  ctx.fillStyle = `rgba(255,255,255,${0.18 * (1 - (t - winAt) / 1200)})`; ctx.fillRect(0, 0, canvas.width, canvas.height);
@@ -269,11 +408,11 @@ function card() {
269
  g.fillStyle = S.bg; g.fillRect(0, 0, 1080, 1350);
270
  g.fillStyle = S.on; g.font = '900 64px system-ui, sans-serif'; g.fillText('PATHMOOR', 80, 130);
271
  g.fillStyle = '#ffffffcc'; g.font = '700 40px system-ui, sans-serif';
272
- g.fillText(mode.kind === 'daily' ? `Daily #${dayNumber(mode.date)} ยท ${mode.date}` : `${packOf(mode.pack).name} ยท board ${mode.level}`, 80, 190);
273
  drawBoard(g, 140, 250, 800, cur, new Uint8Array(cur.length).fill(1), 0);
274
  g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; g.fillText(fmtTime(ms), 80, 1170);
275
  g.font = '700 44px system-ui, sans-serif'; g.fillStyle = S.on;
276
- g.fillText(`${totalTaps()} taps ยท par ${par(board)}`, 80, 1235);
277
  g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif';
278
  g.fillText('Play free: cybermaxtools.com/games/pathmoor', 80, 1300);
279
  return c;
@@ -296,19 +435,31 @@ canvas.addEventListener('keydown', (ev) => {
296
  if (mv) { ev.preventDefault(); focus = Math.min(n - 1, Math.max(0, y + mv[1])) * n + Math.min(n - 1, Math.max(0, x + mv[0])); draw(); }
297
  else if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); tap(focus, ev.shiftKey ? 3 : 1); }
298
  else if (ev.key === 'z' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); undo(); }
 
299
  });
300
  canvas.addEventListener('focus', draw); canvas.addEventListener('blur', draw);
301
  document.addEventListener('visibilitychange', () => { if (document.hidden && started && !done) { ms = elapsed(); started = performance.now(); save(); } });
302
 
303
  $('undo').onclick = undo;
304
- $('restart').onclick = restart;
 
 
 
 
 
 
 
 
 
305
  $('pass-btn').onclick = () => pass.open('header');
306
  document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); });
307
- $('share').onclick = async () => share(GAME, shareText(), PATH, await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`);
308
  ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
309
  $('card').onclick = async () => { download(await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); count(GAME, 'share', 'png'); };
310
  $('practice').onclick = () => {
311
- if (mode.kind === 'pack') {
 
 
312
  const pk = packOf(mode.pack);
313
  if (mode.level < pk.levels) load({ kind: 'pack', pack: pk.id, level: mode.level + 1 }, true); else load({ kind: 'pack', pack: 'sprout', level: 1 }, true);
314
  } else {
@@ -320,7 +471,7 @@ $('stats-btn').onclick = () => {
320
  const st = store('pathmoor:stats') || { days: {} };
321
  const d = Object.values(st.days || {});
322
  const best = d.length ? fmtTime(Math.min(...d.map((x) => x.ms))) : 'โ€“';
323
- toast(`Dailies solved: ${d.length} ยท streak ${st.streak || 0} (best ${st.best || 0}) ยท fastest ${best}`, 4500);
324
  };
325
 
326
  function renderSkins() {
@@ -345,7 +496,8 @@ pass.onChange((pro) => {
345
  renderSkins();
346
  if (mode) renderModes();
347
  });
348
- load(fromUrl());
 
349
 
350
  // Screenshot helper for scripts/games/render_assets.mjs: shows a board mid-solve (no effect on normal play).
351
  window.__cmxShot = async (stage = 1) => {
 
1
+ // Pathmoor: the game screen (canvas board, timer, 30 levels, daily, packs, archive, share, card, themes). (c) 2026 CyberMax.
2
+ import { generate, turn, turnsTo, par, shareGrid, fmtTime, PACKS, bits, LEVELS, generateLevel, isSolvedB, litB, tapB, groupOf, levelStars } from './core.js';
3
+ import * as lv from './levels.js';
4
  import { today, dayNumber, dateOf, count, store, toast, share, intents, download, canvasBlob, pwa, rr, themeToggle } from './cmx.js';
5
  import * as pass from './funpass.js';
6
 
 
20
  const todayIso = today();
21
  const todayNum = dayNumber(todayIso);
22
  let skin = SKINS[store('pathmoor:skin')] ? store('pathmoor:skin') : 'moor';
23
+ let mode; // { kind: 'daily', date } | { kind: 'pack', pack, level } | { kind: 'level', level }
24
+ let board, cur, taps, undoStack, ms, started, done, key, focus = -1, anim = {}, winAt = 0, failed = false;
25
+ let muted = !!store('pathmoor:mute');
26
+ const sfx = lv.sfx(() => muted);
27
+ const prog = lv.progress(GAME, { total: LEVELS.length, free: 20, isPro: () => pass.isPro() });
28
+ let chal = lv.readChallenge(); // ?seed=L7&score=12 or ?seed=2026-10-08&score=30 ("beat my score")
29
+ const MAP = LEVELS.map((l, i) => ({ n: i + 1, name: l.name, twist: l.twist, boss: l.boss }));
30
+ const WORLDS = ['Sprout Moor', 'Twin Fields', 'Highland (Fun Pass)'];
31
 
32
+ pass.init({ game: GAME, name: 'Pathmoor', perks: ['Levels 21-30: reverse stones and 9ร—9 to 10ร—10 boards', '250 bonus boards: Moorland 7ร—7, Highland 8ร—8, Summit 10ร—10', 'Every past daily in the archive', 'Ember, Tide, Dusk and Paper board themes'] });
33
  const install = pwa(GAME, $('install'));
34
  themeToggle($('theme'));
35
 
 
37
 
38
  function packOf(id) { return PACKS.find((p) => p.id === id); }
39
  function locked(m) {
40
+ if (m.kind === 'level') return prog.lock(m.level, { challenge: !!m.challenge });
41
  if (pass.isPro()) return false;
42
  if (m.kind === 'daily') return m.date !== todayIso;
43
  const p = packOf(m.pack);
44
  return !(p.freeLevels && m.level <= p.freeLevels);
45
  }
46
+ function keyOf(m) { return m.kind === 'daily' ? `d:${m.date}` : m.kind === 'level' ? `l:${m.level}` : `p:${m.pack}:${m.level}`; }
47
+ function label(m) { return m.kind === 'daily' ? `#${dayNumber(m.date)}` : m.kind === 'level' ? `Level ${m.level}` : `${packOf(m.pack).name} ยท ${m.level}`; }
48
 
49
  function fromUrl() {
50
  const q = new URLSearchParams(location.search);
51
+ if (chal && chal.level && chal.level <= LEVELS.length) return { kind: 'level', level: chal.level, challenge: true };
52
+ if (chal && chal.date) return { kind: 'daily', date: pass.isPro() || chal.date === todayIso ? chal.date : todayIso };
53
+ const lq = Number(q.get('lv'));
54
+ if (lq >= 1 && lq <= LEVELS.length) return { kind: 'level', level: lq };
55
  const d = q.get('d');
56
  if (d && /^\d{4}-\d{2}-\d{2}$/.test(d) && dayNumber(d) >= 1 && d <= todayIso) return { kind: 'daily', date: d };
57
  const p = packOf(q.get('p') || '');
58
  const l = Number(q.get('l'));
59
  if (p && p.id !== 'daily' && l >= 1 && l <= p.levels) return { kind: 'pack', pack: p.id, level: l };
60
+ // first visit: start on level 1 (2-second tutorial); returning players land on today's daily board
61
+ if (!store('pathmoor:stats') && !prog.cleared()) return { kind: 'level', level: 1 };
62
  return { kind: 'daily', date: todayIso };
63
  }
64
 
65
+ /** Level and daily boards share one shape: holes, twins, reverse stones and fog are empty on plain boards. */
66
+ /** The daily streak (days in a row with a solved daily), 0 once a day is missed. */
67
+ function streakNow() {
68
+ const st = store('pathmoor:stats') || { days: {} };
69
+ return st.days[todayIso] || st.days[dateOf(todayNum - 1)] ? st.streak || 0 : 0;
70
+ }
71
+ function plain(b) {
72
+ const z = b.n * b.n;
73
+ return { ...b, hole: new Uint8Array(z), twin: new Int16Array(z).fill(-1), rev: new Uint8Array(z), fog: false, par: par(b), budget: 0 };
74
+ }
75
+
76
  function load(m, push = false) {
77
+ const lk = locked(m);
78
+ if (lk) {
79
+ if (m.kind === 'level' && lk === 'progress') { toast(`Clear level ${prog.reached()} first.`); return false; }
80
+ pass.open(m.kind === 'daily' ? 'archive' : m.kind === 'level' ? 'level' : m.pack); return false;
81
+ }
82
  mode = m;
83
  key = keyOf(m);
84
+ if (m.kind === 'level') { prog.open(m.level); board = generateLevel(m.level); }
85
+ else board = plain(generate(m.kind === 'daily' ? m.date : `${m.pack}-${m.level}`, m.kind === 'daily' ? 6 : packOf(m.pack).n));
86
+ const n = board.n;
87
+ failed = false;
88
+ $('levels').hidden = true;
89
  const saved = store(`pathmoor:b:${key}`);
90
  if (saved && saved.cur && saved.cur.length === n * n) {
91
  cur = saved.cur; taps = saved.taps || {}; ms = saved.ms || 0; done = !!saved.done; undoStack = [];
92
  } else { cur = board.start.slice(); taps = {}; ms = 0; done = false; undoStack = []; }
93
  started = 0; anim = {}; focus = -1;
94
  $('num').textContent = label(m);
95
+ $('par').textContent = board.par;
96
+ goal();
97
+ banner();
98
  if (push) {
99
  const u = new URL(location.href);
100
+ u.search = m.kind === 'daily' ? (m.date === todayIso ? '' : `?d=${m.date}`) : m.kind === 'level' ? `?lv=${m.level}` : `?p=${m.pack}&l=${m.level}`;
101
  window.history.replaceState(null, '', u.pathname + u.search);
102
  }
103
  renderModes();
104
  $('win').hidden = true;
105
  if (done) showWin(false);
106
  hud(); draw();
107
+ if (m.kind === 'level' && m.level === 1 && !done) setTimeout(() => {
108
+ const i = board.start.findIndex((v, j) => v !== board.sol[j]);
109
+ lv.tutorial(GAME, canvas, { fx: ((i % n) + 0.5) / n, fy: (((i / n) | 0) + 0.5) / n });
110
+ }, 300);
111
+ if (m.kind === 'level' && board.budget && !done) sfx.boss();
112
  return true;
113
  }
114
 
115
+ /** The goal line under the modes: what to do on this board, in one glance. */
116
+ function goal() {
117
+ const L = mode.kind === 'level' ? LEVELS[mode.level - 1] : null;
118
+ const bits2 = [];
119
+ if (L && L.boss) bits2.push('๐Ÿ‘‘ Challenge level');
120
+ bits2.push(`๐ŸŽฏ Light every stone in <b>${board.par}</b> taps for โญโญโญ`);
121
+ if (board.budget) bits2.push(`run out of <b>${board.budget}</b> taps and it resets`);
122
+ if (L && L.twist) bits2.push(`<span class="new-twist">NEW: ${L.twist}</span>`);
123
+ $('goal').innerHTML = bits2.join(' ยท ');
124
+ $('left-wrap').hidden = !board.budget;
125
+ }
126
+
127
+ /** "Beat my score" banner when the page was opened from a friend's link for this same board. */
128
+ function banner() {
129
+ const el = $('challenge');
130
+ const same = chal && chal.score !== null && ((chal.level && mode.kind === 'level' && mode.level === chal.level) || (chal.date && mode.kind === 'daily' && mode.date === chal.date));
131
+ el.hidden = !same;
132
+ if (same) el.innerHTML = `<span>๐ŸŽฏ A friend solved ${mode.kind === 'level' ? `level ${chal.level}` : `daily #${dayNumber(chal.date)}`} in <b>${chal.score}</b> taps. Beat it!</span>`;
133
+ }
134
+
135
+ function showMap() {
136
+ const el = $('levels');
137
+ if (!el.hidden) { el.hidden = true; return; }
138
+ lv.renderMap($('lvmap'), prog, MAP, {
139
+ current: mode.kind === 'level' ? mode.level : 0, worlds: WORLDS,
140
+ onPick: (k) => load({ kind: 'level', level: k }, true),
141
+ onLocked: (k, why) => (why === 'pass' ? pass.open('level') : toast(`Clear level ${prog.reached()} to open level ${k}.`)),
142
+ });
143
+ el.hidden = false;
144
+ el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
145
+ }
146
+
147
  function renderModes() {
148
  const el = $('modes');
149
+ const chips = [{ m: { kind: 'daily', date: todayIso }, t: `Today #${todayNum}${streakNow() ? ` ๐Ÿ”ฅ${streakNow()}` : ''}`, on: mode.kind === 'daily' && mode.date === todayIso },
150
+ { map: true, t: `Levels ${prog.cleared()}/${LEVELS.length} โญ${prog.totalStars()}`, on: mode.kind === 'level' }];
151
  for (const p of PACKS.filter((x) => x.id !== 'daily')) {
152
  const lvl = Math.min(p.levels, (store(`pathmoor:pack:${p.id}`) || 0) + 1);
153
  const m = { kind: 'pack', pack: p.id, level: lvl };
 
160
  b.className = 'chip'; b.type = 'button';
161
  b.setAttribute('aria-pressed', String(!!c.on));
162
  b.innerHTML = `${c.t}${c.lock ? ' <span class="lock">๐Ÿ”’</span>' : ''}`;
163
+ b.onclick = () => (c.archive ? archive() : c.map ? showMap() : load(c.m, true));
164
  el.appendChild(b);
165
  });
166
  }
 
180
  function elapsed() { return ms + (started ? performance.now() - started : 0); }
181
 
182
  function tap(i, k = 1) {
183
+ if (done || failed || i < 0 || board.hole[i]) return;
184
  if (!started) {
185
  started = performance.now();
186
  if (!Object.keys(taps).length && !ms) count(GAME, 'play', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.pack);
187
  }
188
+ if (bits(cur[i]) === 4 && board.twin[i] < 0) return; // a cross never changes
189
+ const before = litCount();
190
+ undoStack.push(groupOf(board, i).map((j) => [j, cur[j]]));
191
+ tapB(board, cur, i, k);
192
  taps[i] = (taps[i] || 0) + 1;
193
+ for (const j of groupOf(board, i)) anim[j] = { from: (board.rev[j] ? -1 : 1) * (k === 1 ? -90 : 90), t0: performance.now() };
194
  if (navigator.vibrate) try { navigator.vibrate(8); } catch { /* */ }
195
+ const gain = litCount() - before;
196
+ if (gain > 0) sfx.good(Math.min(20, gain)); else sfx.tap();
197
+ if (isSolvedB(board, cur)) win();
198
+ else if (board.budget && totalTaps() >= board.budget) fail();
199
  save(); hud(); loop();
200
  }
201
+ function litCount() { return litB(board, cur).reduce((a, b) => a + b, 0); }
202
+
203
+ /** Challenge level: out of taps. Shake, buzz, and the board resets by itself (one tap to try again). */
204
+ function fail() {
205
+ failed = true; sfx.hit(); lv.shake(canvas, 10, 320);
206
+ if (navigator.vibrate) try { navigator.vibrate([30, 40, 30]); } catch { /* */ }
207
+ toast(`Out of taps! The board resets. Par is ${board.par}.`, 1800);
208
+ setTimeout(() => { failed = false; restart(true); }, 650);
209
+ }
210
 
211
  function undo() {
212
  if (done || !undoStack.length) return;
213
+ for (const [i, m] of undoStack.pop()) { cur[i] = m; anim[i] = { from: 90, t0: performance.now() }; }
 
 
214
  save(); hud(); loop();
215
  }
216
 
217
+ /** Instant restart: one tap, no question (a finished result stays saved in your stats and stars). */
218
+ function restart(quiet = false) {
219
+ if (!quiet) sfx.tap();
220
  cur = board.start.slice(); taps = {}; ms = 0; started = 0; done = false; undoStack = [];
221
  store(`pathmoor:b:${key}`, null);
222
  $('win').hidden = true;
 
228
  function hud() {
229
  $('time').textContent = fmtTime(done ? ms : elapsed());
230
  $('taps').textContent = totalTaps();
231
+ const on = litB(board, cur);
232
+ const cells = on.length - board.hole.reduce((a, b) => a + b, 0);
233
+ $('lit').textContent = `${Math.round((on.reduce((a, b) => a + b, 0) / cells) * 100)}%`;
234
+ if (board.budget) $('left').textContent = Math.max(0, board.budget - totalTaps());
235
  }
236
 
237
  function win() {
238
  ms = elapsed(); started = 0; done = true; winAt = performance.now();
239
+ count(GAME, 'win', mode.kind === 'daily' ? (mode.date === todayIso ? 'daily' : 'archive') : mode.kind === 'level' ? `level-${mode.level}` : mode.pack);
240
+ sfx.win(); lv.burstAt(canvas, 0.5, 0.5, { n: 60, speed: 9, colors: [SKINS[skin].on, SKINS[skin].lamp, '#FFFFFF'] });
241
+ if (mode.kind === 'level') {
242
+ const st = levelStars(totalTaps(), board.par);
243
+ mode.result = prog.record(mode.level, st, totalTaps());
244
+ mode.result.got = st;
245
+ for (let k = 0; k < st; k++) sfx.star(k);
246
+ } else if (mode.kind === 'daily') {
247
  const st = store('pathmoor:stats') || { days: {}, streak: 0, best: 0 };
248
  if (!st.days[mode.date]) {
249
+ st.days[mode.date] = { ms: Math.round(ms), taps: totalTaps(), par: board.par };
250
  if (mode.date === todayIso) {
251
  const y = dateOf(todayNum - 1);
252
  st.streak = st.days[y] ? (st.streak || 0) + 1 : 1;
 
257
  } else {
258
  store(`pathmoor:pack:${mode.pack}`, Math.max(store(`pathmoor:pack:${mode.pack}`) || 0, mode.level));
259
  }
260
+ renderModes();
261
  save();
262
  setTimeout(() => showWin(true), 650);
263
  install.offer();
264
  }
265
 
266
  function shareText() {
267
+ const t = totalTaps(), p = board.par;
268
+ if (mode.kind === 'level') return `Pathmoor level ${mode.level} ๐Ÿฎ ${lv.starText(prog.stars(mode.level))}\n${t} taps (par ${p}) ยท ${fmtTime(ms)}\nCan you beat it?`;
269
  const head = mode.kind === 'daily' ? `Pathmoor #${dayNumber(mode.date)} ๐Ÿฎ` : `Pathmoor ${packOf(mode.pack).name} #${mode.level} ๐Ÿฎ`;
270
+ const streak = mode.kind === 'daily' && mode.date === todayIso && streakNow() > 1 ? ` ยท ๐Ÿ”ฅ${streakNow()}` : '';
271
+ return `${head} ${fmtTime(ms)} ยท ${t} taps (par ${p})${t <= p ? ' โญ perfect' : ''}${streak}\n${shareGrid(board, taps)}`;
272
+ }
273
+ /** The "beat my score" link: the same board, with my taps to beat. */
274
+ function beatLink() {
275
+ if (mode.kind === 'level') return lv.challengeLink(PATH, GAME, { level: mode.level, score: totalTaps() });
276
+ if (mode.kind === 'daily') return lv.challengeLink(PATH, GAME, { date: mode.date, score: totalTaps() });
277
+ return `https://cybermaxtools.com${PATH}?p=${mode.pack}&l=${mode.level}`;
278
  }
279
 
280
  function showWin(scroll) {
281
  const st = store('pathmoor:stats') || {};
282
+ const t = totalTaps(), p = board.par;
283
  $('win-title').textContent = t <= p ? 'Perfect! Every stone lit in par.' : 'Every stone is lit!';
284
+ const isLv = mode.kind === 'level';
285
+ $('win-stars').hidden = !isLv;
286
+ if (isLv) {
287
+ const st = prog.stars(mode.level);
288
+ $('win-stars').innerHTML = lv.starsHtml(st);
289
+ $('win-title').textContent = st === 3 ? `Level ${mode.level}: perfect!` : `Level ${mode.level} cleared!`;
290
+ }
291
+ $('next-lv').hidden = !isLv || mode.level >= LEVELS.length;
292
+ $('retry').hidden = !isLv;
293
+ if (isLv && mode.level < LEVELS.length) $('next-lv').textContent = prog.lock(mode.level + 1) === 'pass' ? `Level ${mode.level + 1} ๐Ÿ”’ Fun Pass` : `Next: level ${mode.level + 1} โ–ถ`;
294
  $('win-line').textContent = `${fmtTime(ms)} ยท ${t} taps (par ${p})` + (mode.kind === 'daily' && mode.date === todayIso && st.streak ? ` ยท ๐Ÿ”ฅ ${st.streak}-day streak` : '');
295
+ $('win-grid').textContent = isLv ? '' : shareGrid(board, taps);
296
+ $('win-grid').hidden = isLv;
297
+ if (chal && chal.score !== null && !$('challenge').hidden) {
298
+ $('win-line').textContent += t < chal.score ? ` ยท ๐ŸŽฏ You beat your friend's ${chal.score}!` : t === chal.score ? ` ยท ๐ŸŽฏ Tied with your friend (${chal.score})` : ` ยท ๐ŸŽฏ ${t - chal.score} more than your friend. Retry?`;
299
+ }
300
  const text = shareText();
301
+ const it = intents(GAME, `${text}\n๐ŸŽฏ ${beatLink()}`, PATH);
302
  $('sx').href = it.x; $('sb').href = it.bsky; $('sw').href = it.whatsapp;
303
  const pr = $('practice');
304
+ if (isLv) pr.textContent = 'Play today\'s daily board';
305
+ else if (mode.kind === 'pack') {
306
  const pk = packOf(mode.pack);
307
  pr.textContent = mode.level < pk.levels ? `Next board: ${pk.name} #${mode.level + 1}` : 'Pack complete! Try another pack';
308
+ } else pr.textContent = `Play level ${prog.reached()}`;
309
  $('win').hidden = false;
310
  pass.afterWin($('win'), 'win');
311
  tick();
 
321
 
322
  // ------------------------------------------------------------------ drawing
323
 
324
+ const TWIN = ['#5CF2FF', '#FF8FD8', '#9BE15D', '#FFD166', '#FF7A45', '#B79CFF'];
325
+ function mix(a, b, k) {
326
+ const p = (h) => [1, 3, 5].map((o) => parseInt(h.slice(o, o + 2), 16));
327
+ const A = p(a), B = p(b);
328
+ return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * k)).join(',')})`;
329
+ }
330
+ /** Fog: a stone shows only when it is lit or next to a lit stone. */
331
+ function fogMask(on) {
332
+ if (!board.fog || done) return null;
333
+ const n = board.n, f = new Uint8Array(n * n).fill(1);
334
+ for (let i = 0; i < n * n; i++) {
335
+ if (!on[i]) continue;
336
+ const x = i % n, y = (i / n) | 0;
337
+ f[i] = 0;
338
+ for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { const X = x + dx, Y = y + dy; if (X >= 0 && Y >= 0 && X < n && Y < n) f[Y * n + X] = 0; }
339
+ }
340
+ return f;
341
+ }
342
  function drawBoard(c, x0, y0, size, cells, on, t, focusIdx = -1) {
343
  const S = SKINS[skin];
344
+ const fogged = fogMask(on);
345
  const n = board.n, cs = size / n, pad = cs * 0.06, w = cs * 0.2;
346
  c.save();
347
  c.fillStyle = S.bg; rr(c, x0, y0, size, size, size * 0.04); c.fill();
348
  for (let i = 0; i < n * n; i++) {
349
  const x = x0 + (i % n) * cs, y = y0 + ((i / n) | 0) * cs, cx = x + cs / 2, cy = y + cs / 2;
350
+ if (board.hole[i]) continue; // a hole in the moor: nothing to draw
351
+ if (fogged && fogged[i]) { c.fillStyle = S.off; c.globalAlpha = 0.35; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill(); c.globalAlpha = 1; continue; }
352
+ c.fillStyle = board.rev[i] ? mix(S.stone, '#8A5CF6', 0.35) : S.stone; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.fill();
353
+ if (board.twin[i] >= 0) { c.fillStyle = TWIN[Math.min(i, board.twin[i]) % TWIN.length]; c.beginPath(); c.arc(x + cs * 0.2, y + cs * 0.2, cs * 0.075, 0, Math.PI * 2); c.fill(); }
354
+ if (board.rev[i]) { c.strokeStyle = '#B79CFF'; c.lineWidth = Math.max(2, cs * 0.035); c.beginPath(); c.arc(x + cs * 0.8, y + cs * 0.2, cs * 0.07, Math.PI * 0.2, Math.PI * 1.7); c.stroke(); }
355
  if (i === focusIdx) { c.strokeStyle = S.on; c.lineWidth = 3; rr(c, x + pad, y + pad, cs - 2 * pad, cs - 2 * pad, cs * 0.18); c.stroke(); }
356
  let ang = 0;
357
  const a = anim[i];
 
383
  function draw() {
384
  const t = performance.now();
385
  ctx.clearRect(0, 0, canvas.width, canvas.height);
386
+ const on = done ? new Uint8Array(cur.length).fill(1) : litB(board, cur);
387
  drawBoard(ctx, 0, 0, canvas.width, cur, on, t, document.activeElement === canvas ? focus : -1);
388
  if (done && t - winAt < 1200) {
389
  ctx.fillStyle = `rgba(255,255,255,${0.18 * (1 - (t - winAt) / 1200)})`; ctx.fillRect(0, 0, canvas.width, canvas.height);
 
408
  g.fillStyle = S.bg; g.fillRect(0, 0, 1080, 1350);
409
  g.fillStyle = S.on; g.font = '900 64px system-ui, sans-serif'; g.fillText('PATHMOOR', 80, 130);
410
  g.fillStyle = '#ffffffcc'; g.font = '700 40px system-ui, sans-serif';
411
+ g.fillText(mode.kind === 'daily' ? `Daily #${dayNumber(mode.date)} ยท ${mode.date}` : mode.kind === 'level' ? `Level ${mode.level} ยท ${LEVELS[mode.level - 1].name} ${lv.starText(prog.stars(mode.level))}` : `${packOf(mode.pack).name} ยท board ${mode.level}`, 80, 190);
412
  drawBoard(g, 140, 250, 800, cur, new Uint8Array(cur.length).fill(1), 0);
413
  g.fillStyle = '#fff'; g.font = '900 96px system-ui, sans-serif'; g.fillText(fmtTime(ms), 80, 1170);
414
  g.font = '700 44px system-ui, sans-serif'; g.fillStyle = S.on;
415
+ g.fillText(`${totalTaps()} taps ยท par ${board.par} ยท can you beat it?`, 80, 1235);
416
  g.fillStyle = '#ffffffaa'; g.font = '600 32px system-ui, sans-serif';
417
  g.fillText('Play free: cybermaxtools.com/games/pathmoor', 80, 1300);
418
  return c;
 
435
  if (mv) { ev.preventDefault(); focus = Math.min(n - 1, Math.max(0, y + mv[1])) * n + Math.min(n - 1, Math.max(0, x + mv[0])); draw(); }
436
  else if (ev.key === ' ' || ev.key === 'Enter') { ev.preventDefault(); tap(focus, ev.shiftKey ? 3 : 1); }
437
  else if (ev.key === 'z' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); undo(); }
438
+ else if (ev.key === 'r' || ev.key === 'R') { ev.preventDefault(); restart(); }
439
  });
440
  canvas.addEventListener('focus', draw); canvas.addEventListener('blur', draw);
441
  document.addEventListener('visibilitychange', () => { if (document.hidden && started && !done) { ms = elapsed(); started = performance.now(); save(); } });
442
 
443
  $('undo').onclick = undo;
444
+ $('restart').onclick = () => restart();
445
+ $('retry').onclick = () => restart();
446
+ $('next-lv').onclick = () => load({ kind: 'level', level: mode.level + 1 }, true) && canvas.scrollIntoView({ behavior: 'smooth', block: 'center' });
447
+ $('beat').onclick = async () => {
448
+ const r = await lv.shareChallenge(`I solved Pathmoor ${mode.kind === 'level' ? `level ${mode.level}` : mode.kind === 'daily' ? `#${dayNumber(mode.date)}` : 'this board'} in ${totalTaps()} taps. Can you beat me? ๐Ÿฎ`, beatLink());
449
+ if (r === 'copied') toast('Challenge link copied. Send it to a friend!');
450
+ if (r !== 'cancelled') count(GAME, 'share', 'challenge');
451
+ };
452
+ $('mute').textContent = muted ? '๐Ÿ”‡' : '๐Ÿ”Š';
453
+ $('mute').onclick = () => { muted = !muted; store('pathmoor:mute', muted); $('mute').textContent = muted ? '๐Ÿ”‡' : '๐Ÿ”Š'; };
454
  $('pass-btn').onclick = () => pass.open('header');
455
  document.addEventListener('click', (e) => { const b = e.target.closest('[data-pass]'); if (b) pass.open(b.dataset.pass); });
456
+ $('share').onclick = async () => share(GAME, `${shareText()}\n๐ŸŽฏ Beat me: ${beatLink()}`, PATH, await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`);
457
  ['sx', 'sb', 'sw'].forEach((id) => $(id).addEventListener('click', () => count(GAME, 'share', { sx: 'x', sb: 'bsky', sw: 'whatsapp' }[id])));
458
  $('card').onclick = async () => { download(await canvasBlob(card()), `pathmoor-${key.replace(/[:]/g, '-')}.png`); count(GAME, 'share', 'png'); };
459
  $('practice').onclick = () => {
460
+ if (mode.kind === 'level') load({ kind: 'daily', date: todayIso }, true);
461
+ else if (mode.kind === 'daily') load({ kind: 'level', level: prog.reached() }, true) || load({ kind: 'level', level: Math.min(prog.reached(), prog.free) }, true);
462
+ else if (mode.kind === 'pack') {
463
  const pk = packOf(mode.pack);
464
  if (mode.level < pk.levels) load({ kind: 'pack', pack: pk.id, level: mode.level + 1 }, true); else load({ kind: 'pack', pack: 'sprout', level: 1 }, true);
465
  } else {
 
471
  const st = store('pathmoor:stats') || { days: {} };
472
  const d = Object.values(st.days || {});
473
  const best = d.length ? fmtTime(Math.min(...d.map((x) => x.ms))) : 'โ€“';
474
+ toast(`Dailies solved: ${d.length} ยท streak ${st.streak || 0} (best ${st.best || 0}) ยท fastest ${best} ยท levels ${prog.cleared()}/${LEVELS.length}, ${prog.totalStars()} โญ`, 4500);
475
  };
476
 
477
  function renderSkins() {
 
496
  renderSkins();
497
  if (mode) renderModes();
498
  });
499
+ // a locked link (pass level, level not reached yet) falls back to the next open level, then today's daily
500
+ load(fromUrl()) || (!prog.lock(prog.reached()) && load({ kind: 'level', level: prog.reached() })) || load({ kind: 'daily', date: todayIso });
501
 
502
  // Screenshot helper for scripts/games/render_assets.mjs: shows a board mid-solve (no effect on normal play).
503
  window.__cmxShot = async (stage = 1) => {
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>Pathmoor: free daily path puzzle. Light every stone | CyberMax</title>
4
- <meta name="description" content="Pathmoor is a free daily path puzzle: tap stones to turn them until every path glows from the lantern. A new board every day, about 2 minutes, share your time like Wordle. Works offline, installs on your phone.">
5
  <link rel="canonical" href="https://cybermaxtools.com/games/pathmoor/">
6
  <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
- <meta property="og:title" content="Pathmoor: light every stone">
8
- <meta property="og:description" content="A new path puzzle every day. Tap to turn stones until every path glows. Share your time.">
9
  <meta property="og:url" content="https://cybermaxtools.com/games/pathmoor/">
10
  <meta property="og:image" content="https://cybermaxtools.com/games/pathmoor/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/pathmoor/og.png">
@@ -15,47 +15,64 @@
15
  <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Pathmoor">
16
  <link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
17
  <script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('pathmoor:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
18
- <script type="application/ld+json">{"@context":"https://schema.org","@type":"VideoGame","name":"Pathmoor","url":"https://cybermaxtools.com/games/pathmoor/","image":"https://cybermaxtools.com/games/pathmoor/og.png","description":"A free daily path puzzle: tap stones to turn them until every path glows from the lantern.","genre":["Puzzle","Logic"],"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":"Daily puzzle, free"},{"@type":"Offer","price":"2.99","priceCurrency":"USD","description":"CyberMax Fun Pass, monthly"}]}</script>
 
 
 
19
  </head><body>
20
  <div class="wrap">
21
  <header class="top"><a class="brand" href="https://cybermaxtools.com/games/"><svg viewBox="0 0 512 512" aria-hidden="true"><clipPath id="lg"><rect width="512" height="512" rx="116"/></clipPath><g clip-path="url(#lg)"><rect width="512" height="512" fill="#FF6A3D"/><path d="M512 250V512H250Z" fill="#D9481F"/></g><g transform="translate(86 86) scale(3.4)"><path d="M66 27 A27 27 0 1 0 66 73" fill="none" stroke="#11141B" stroke-width="17" stroke-linecap="round"/><rect x="66" y="41" width="20" height="18" rx="4" fill="#11141B"/></g></svg>CyberMax</a>
22
- <nav><a href="https://cybermaxtools.com/games/">Games</a></nav><button class="icon" id="stats-btn" type="button" aria-label="Your stats">๐Ÿ“Š</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>Pathmoor <span id="num" class="num"></span> <span class="pro-badge">PASS</span></h1>
26
- <p class="muted tag">Tap stones to turn them. Light every path from the lantern.</p></div>
27
  <div class="chips" id="modes" role="group" aria-label="Board"></div>
28
- <div class="hud"><span>โฑ <b id="time">0:00</b></span><span>Taps <b id="taps">0</b></span><span>Par <b id="par">0</b></span><span>Lit <b id="lit">0%</b></span></div>
 
 
 
29
  <div class="board-wrap"><canvas id="board" width="720" height="720" aria-label="Pathmoor board. Use arrow keys to move and Space to turn a stone." tabindex="0"></canvas></div>
30
  <div class="row under"><button class="btn ghost sm" id="undo" type="button">โ†ถ Undo</button><button class="btn ghost sm" id="restart" type="button">Restart</button><button class="btn ghost sm" id="install" type="button" hidden>๐Ÿ“ฒ Add to Home Screen</button><button class="btn pass sm" id="pass-btn" type="button">โœจ Fun Pass</button></div>
31
  <section class="panel win" id="win" hidden aria-live="polite">
32
  <h2 id="win-title">Every stone is lit!</h2>
 
33
  <p id="win-line" class="muted"></p>
34
  <pre id="win-grid" class="grid"></pre>
 
35
  <div class="row"><button class="btn" id="share" type="button">Share result</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>
36
  <p class="muted small" id="next"></p>
37
  <div class="row"><button class="btn ghost sm" id="practice" type="button">Play a practice board</button></div>
38
- <div class="nudge"><span>Loved it? The <b>Fun Pass</b> adds 250 bigger boards, every past daily and colour themes.</span><button class="btn pass sm" data-pass="win" type="button">See the pass</button></div>
39
  </section>
40
  <div class="row skins" id="skins" aria-label="Board theme"></div>
41
  </section>
42
 
43
- <section class="seo">
 
 
44
  <h2>How to play Pathmoor</h2>
45
- <p>The board is a square of path stones. One stone holds the lantern. Tap any stone to turn it a quarter turn. When a path joins the lantern it glows. The puzzle is solved when <b>every stone glows and no path points into a wall or the edge</b>. Fewer taps is better: par is the fewest taps that can solve today's board.</p>
46
- <h3>Tips</h3>
47
- <ul><li>Start at the edges: a path can never point off the board, so corner stones often have only one way to sit.</li><li>Dead-end stones (one opening) must point into a neighbour that opens back.</li><li>Straight stones on an edge run along it, never into it.</li></ul>
48
- <h3>A new board every day</h3>
49
- <p>Everyone gets the same daily board, and it changes at your midnight. Share your time and taps with the grid: ๐ŸŸฉ a stone you set in par, ๐ŸŸจ a few extra turns, ๐ŸŸง a lot, โฌœ a stone that was already right. Keep your streak going. Pathmoor works offline and installs on your phone like an app (no app store, no sign-up).</p>
50
- <h3>Free, with an optional Fun Pass</h3>
51
- <p>The daily board and three practice boards are free for everyone. The optional <b>CyberMax Fun Pass</b> ($2.99/month, $19/year, or $29.99 for life) adds the Moorland 7ร—7, Highland 8ร—8 and Summit 10ร—10 packs (250 boards), the full archive of past dailies and colour themes, removes the supporter nudges, and works in every CyberMax game.</p>
52
- <h3>FAQ</h3>
53
- <p><b>Is there only one solution?</b> Any arrangement that lights every stone with no loose ends counts. <b>Does it track me?</b> No accounts, no cookies; only anonymous counts (a game was played or shared). <b>Why not just play another daily puzzle?</b> Pathmoor is a turn-the-tiles puzzle you can finish on a coffee break, with a fresh board daily and a par score to beat.</p>
54
- <h2>More free games from CyberMax</h2>
55
- <div class="more" id="more"><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/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/toys/"><b>Web toys</b><span>Roast your website, vibe-check a name</span></a></div>
 
 
 
 
 
 
 
56
  </section>
57
  </main>
58
  <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>
59
  </div>
60
  <script type="module" src="game.js"></script>
61
- <!-- 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>Pathmoor: Free Daily Logic Puzzle Game Online, Rotate the Paths</title>
4
+ <meta name="description" content="Play Pathmoor free: a logic puzzle with 30 levels and a daily board. Turn stones until every path glows from the lantern, earn stars, beat a friend&#x27;s score.">
5
  <link rel="canonical" href="https://cybermaxtools.com/games/pathmoor/">
6
  <meta property="og:type" content="website"><meta property="og:site_name" content="CyberMax">
7
+ <meta property="og:title" content="Pathmoor: Free Daily Logic Puzzle Game Online">
8
+ <meta property="og:description" content="Play Pathmoor free: a logic puzzle with 30 levels and a daily board. Turn stones until every path glows from the lantern, earn stars, beat a friend&#x27;s score.">
9
  <meta property="og:url" content="https://cybermaxtools.com/games/pathmoor/">
10
  <meta property="og:image" content="https://cybermaxtools.com/games/pathmoor/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/pathmoor/og.png">
 
15
  <meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-title" content="Pathmoor">
16
  <link rel="stylesheet" href="fun.css"><link rel="stylesheet" href="style.css">
17
  <script>try{var t=localStorage.getItem('cm-theme');if(t)document.documentElement.dataset.theme=t;var s=localStorage.getItem('pathmoor:skin');if(s)document.documentElement.dataset.skin=JSON.parse(s)}catch(e){}</script>
18
+ <meta name="twitter:title" content="Pathmoor: Free Daily Logic Puzzle Game Online">
19
+ <meta name="twitter:description" content="Play Pathmoor free: a logic puzzle with 30 levels and a daily board. Turn stones until every path glows from the lantern, 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/pathmoor/#game", "name": "Pathmoor", "alternateName": "Daily logic puzzle game online free", "url": "https://cybermaxtools.com/games/pathmoor/", "image": "https://cybermaxtools.com/games/pathmoor/og.png", "description": "Pathmoor is a free logic puzzle for your browser. Turn stones a quarter turn at a time until every path connects back to the lantern and glows. 30 levels add holes, twin stones, fog and reverse stones, with a challenge level every 10. Earn up to 3 stars by matching par, play the same daily board as everyone else, keep a streak and send a friend a link to beat your score.", "genre": ["Puzzle", "Logic", "Daily puzzle", "Brain game"], "gamePlatform": ["Web browser", "Android", "iOS", "Windows", "macOS", "ChromeOS"], "applicationCategory": "Game", "operatingSystem": "Any (web browser)", "playMode": "SinglePlayer", "inLanguage": "en", "isAccessibleForFree": true, "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, "author": {"@type": "Organization", "name": "CyberMax", "url": "https://cybermaxtools.com/"}, "publisher": {"@type": "Organization", "name": "CyberMax"}, "datePublished": "2026-10-02", "keywords": "Daily logic puzzle game online free, free daily path puzzle, unblocked, no download, browser game", "trailer": {"@id": "https://cybermaxtools.com/games/pathmoor/#video"}}, {"@type": "FAQPage", "@id": "https://cybermaxtools.com/games/pathmoor/#faq", "mainEntity": [{"@type": "Question", "name": "Is Pathmoor free?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. The daily board and levels 1-20 are free."}}, {"@type": "Question", "name": "How many levels are there?", "acceptedAnswer": {"@type": "Answer", "text": "30, with a new twist about every 5 levels and a challenge level every 10. Levels 21-30 come with the CyberMax Fun Pass."}}, {"@type": "Question", "name": "When does a new daily board come out?", "acceptedAnswer": {"@type": "Answer", "text": "Every day at your midnight."}}, {"@type": "Question", "name": "What is par?", "acceptedAnswer": {"@type": "Answer", "text": "The fewest taps that can solve the board. Match it for 3 stars."}}, {"@type": "Question", "name": "Can I challenge a friend?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. After a win, tap Challenge a friend to send a link to the same board with your score."}}, {"@type": "Question", "name": "Does it work offline?", "acceptedAnswer": {"@type": "Answer", "text": "Yes, after your first visit, and it installs like an app."}}]}, {"@type": "VideoObject", "name": "Pathmoor demo: Daily path puzzle: light every stone", "description": "A 9-second screen recording of the live Pathmoor page: Today's board. Tap a stone to turn it; Every path must glow from the lantern; Solved: a shareable result. Real result, no voice.", "thumbnailUrl": ["https://cybermax.cybermax-tools.workers.dev/media/demos/pathmoor/poster.jpg"], "uploadDate": "2026-10-02T18:13:52+00:00", "duration": "PT9S", "contentUrl": "https://cybermax.cybermax-tools.workers.dev/media/demos/pathmoor/demo-16x9.mp4", "embedUrl": "https://cybermaxtools.com/games/pathmoor/", "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/pathmoor/#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="stats-btn" type="button" aria-label="Your stats">๐Ÿ“Š</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>Pathmoor <span id="num" class="num"></span> <span class="pro-badge">PASS</span> <small class="h1kw">Free daily path puzzle</small></h1>
29
+ <p class="muted tag">Tap stones to turn them. Light every path from the lantern. 30 levels, a daily board for everyone.</p></div>
30
  <div class="chips" id="modes" role="group" aria-label="Board"></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="hud"><span>โฑ <b id="time">0:00</b></span><span>Taps <b id="taps">0</b></span><span>Par <b id="par">0</b></span><span>Lit <b id="lit">0%</b></span><span id="left-wrap" hidden>Taps left <b id="left">0</b></span></div>
35
  <div class="board-wrap"><canvas id="board" width="720" height="720" aria-label="Pathmoor board. Use arrow keys to move and Space to turn a stone." tabindex="0"></canvas></div>
36
  <div class="row under"><button class="btn ghost sm" id="undo" type="button">โ†ถ Undo</button><button class="btn ghost sm" id="restart" type="button">Restart</button><button class="btn ghost sm" id="install" type="button" hidden>๐Ÿ“ฒ Add to Home Screen</button><button class="btn pass sm" id="pass-btn" type="button">โœจ Fun Pass</button></div>
37
  <section class="panel win" id="win" hidden aria-live="polite">
38
  <h2 id="win-title">Every stone is lit!</h2>
39
+ <div class="lv-stars" id="win-stars" hidden></div>
40
  <p id="win-line" class="muted"></p>
41
  <pre id="win-grid" class="grid"></pre>
42
+ <div class="row"><button class="btn" id="next-lv" type="button" hidden>Next level โ–ถ</button><button class="btn ghost" id="retry" type="button" hidden>โ†ป Retry</button><button class="btn ghost" id="beat" type="button">๐ŸŽฏ Challenge a friend</button></div>
43
  <div class="row"><button class="btn" id="share" type="button">Share result</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>
44
  <p class="muted small" id="next"></p>
45
  <div class="row"><button class="btn ghost sm" id="practice" type="button">Play a practice board</button></div>
46
+ <div class="nudge"><span>Loved it? The <b>Fun Pass</b> opens levels 21-30, 250 bonus boards, every past daily and colour themes.</span><button class="btn pass sm" data-pass="win" type="button">See the pass</button></div>
47
  </section>
48
  <div class="row skins" id="skins" aria-label="Board theme"></div>
49
  </section>
50
 
51
+ <section class="seo" id="guide">
52
+ <h2>Pathmoor: daily logic puzzle game online free</h2>
53
+ <p>Pathmoor is a free logic puzzle for your browser. Turn stones a quarter turn at a time until every path connects back to the lantern and glows. 30 levels add holes, twin stones, fog and reverse stones, with a challenge level every 10. Earn up to 3 stars by matching par, play the same daily board as everyone else, keep a streak and send a friend a link to beat your score.</p>
54
  <h2>How to play Pathmoor</h2>
55
+ <ol><li>One stone holds the lantern.</li><li>Tap any stone to rotate it a quarter turn.</li><li>Paths connected to the lantern glow.</li><li>Solve the board when every stone glows and no path points into a wall.</li><li>Match par, the fewest possible taps, for 3 stars.</li></ol>
56
+ <h3>Controls</h3>
57
+ <p><b>Phone and tablet:</b> Tap a stone to turn it.</p>
58
+ <p><b>Desktop, laptop and Chromebook:</b> Click a stone to turn it.</p>
59
+ <h2>Pathmoor tips and strategy</h2>
60
+ <ul><li>Start with edge and corner stones: paths can never point off the board.</li><li>Dead-end stones must point into a neighbour that opens back.</li><li>Straight stones on an edge always run along it.</li><li>Work inward from what is forced before guessing.</li></ul>
61
+ <h2>Game modes</h2>
62
+ <ul><li>30 levels: holes, twin stones that turn together, fog and reverse stones, plus a tap-budget challenge level every 10. Levels 1-20 are free.</li><li>Daily board, the same for everyone, with a streak and a shareable grid.</li><li>Beat-my-score links: send a friend the same level with your tap count.</li><li>Fun Pass: levels 21-30, the full archive of past boards and 250 bonus boards.</li></ul>
63
+ <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>
64
+ <h2>Pathmoor FAQ</h2>
65
+ <h3>Is Pathmoor free?</h3><p>Yes. The daily board and levels 1-20 are free.</p>
66
+ <h3>How many levels are there?</h3><p>30, with a new twist about every 5 levels and a challenge level every 10. Levels 21-30 come with the CyberMax Fun Pass.</p>
67
+ <h3>When does a new daily board come out?</h3><p>Every day at your midnight.</p>
68
+ <h3>What is par?</h3><p>The fewest taps that can solve the board. Match it for 3 stars.</p>
69
+ <h3>Can I challenge a friend?</h3><p>Yes. After a win, tap Challenge a friend to send a link to the same board with your score.</p>
70
+ <h3>Does it work offline?</h3><p>Yes, after your first visit, and it installs like an app.</p>
71
+ <h2>Similar free games</h2>
72
+ <div class="more"><a href="https://cybermaxtools.com/games/townduel/"><b>Townduel</b><span>Which US town costs more? 10 daily duels</span></a><a href="https://cybermaxtools.com/games/brimbloom/"><b>Brimbloom</b><span>Stop the bloom on the brim</span></a><a href="https://cybermaxtools.com/games/kenwren/"><b>Kenwren</b><span>How well do your friends know you?</span></a><a href="https://cybermaxtools.com/games/"><b>All CyberMax games</b><span>Free browser games, no download</span></a></div>
73
  </section>
74
  </main>
75
  <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>
76
  </div>
77
  <script type="module" src="game.js"></script>
78
+ </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,5 @@
12
  .skins button{width:36px;height:36px;border-radius:50%;border:3px solid var(--line);cursor:pointer;position:relative}
13
  .skins button[aria-pressed=true]{border-color:var(--ink)}
14
  .skins button.locked::after{content:"๐Ÿ”’";position:absolute;right:-6px;bottom:-6px;font-size:12px}
 
 
 
12
  .skins button{width:36px;height:36px;border-radius:50%;border:3px solid var(--line);cursor:pointer;position:relative}
13
  .skins button[aria-pressed=true]{border-color:var(--ink)}
14
  .skins button.locked::after{content:"๐Ÿ”’";position:absolute;right:-6px;bottom:-6px;font-size:12px}
15
+ .new-twist{background:var(--acc);color:var(--acc-ink);border-radius:8px;padding:1px 8px;font-weight:800;font-size:13px}
16
+ .lvmap{margin:10px 0}#goal{min-height:24px;color:var(--muted)}#goal b{color:var(--ink)}
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 = 'pathmoor';
4
- const CACHE = `cmx-${GAME}-4b226fa7a1`;
5
- const FILES = ["index.html", "cmx.js", "core.js", "fun.css", "funpass.js", "game.js", "icon-192.png", "icon-512.png", "icon-maskable.png", "icon.svg", "index.html", "manifest.webmanifest", "style.css"];
6
 
7
  self.addEventListener('install', (e) => {
8
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));
 
1
  // CyberMax game service worker: the game works offline after the first visit. Cache-first for the game's own files,
2
  // network-first for the page itself (so updates arrive), never caches API calls or other sites. (c) 2026 CyberMax.
3
  const GAME = 'pathmoor';
4
+ const CACHE = `cmx-${GAME}-0fe363ed32`;
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", "style.css"];
6
 
7
  self.addEventListener('install', (e) => {
8
  e.waitUntil(caches.open(CACHE).then((c) => c.addAll(FILES)).then(() => self.skipWaiting()));