fuzzyworld

word-waltz / files / src / main.ts

main.ts

Blame: the commit that last changed each line · file · history

f755bca6 2026-03-23 Pavel Safronov1/**
2 * main.ts — application entry point
3 *
4 * Responsibilities:
5 * - Parse the URL fragment to extract gameId and encryption key
6 * - Route to the correct view: game creation, word picking, or Wordle solving
1c548168 2026-03-24 Pavel Safronov7 * - Handle all game-state writes (encrypt → Firestore)
8 * - Subscribe to real-time Firestore updates and re-render on change
f755bca6 2026-03-23 Pavel Safronov9 */
10
560597e5 2026-03-24 Pavel Safronov11import { parseLink } from './utils/shareLink.js';
1c548168 2026-03-24 Pavel Safronov12import { generateKey, exportKey, importKey, encrypt, decrypt } from './crypto/encryption.js';
51cb47e7 2026-03-26 Pavel Safronov13import { createGame, getGame, updateGame, subscribeToGame, deleteGame, storePushSubscription } from './firebase/gameService.js';
560597e5 2026-03-24 Pavel Safronov14import { getStoredGames, upsertStoredGame, removeStoredGame, clearStoredGames } from './utils/gameRegistry.js';
15import { buildLink } from './utils/shareLink.js';
1c548168 2026-03-24 Pavel Safronov16import { mountWordPicker } from './components/WordPicker.js';
17import { mountWordleBoard } from './components/WordleBoard.js';
18import { mountScoreboard } from './components/Scoreboard.js';
8008bb36 2026-03-26 Pavel Safronov19import { mountSoloScoreboard } from './components/SoloScoreboard.js';
1c548168 2026-03-24 Pavel Safronov20import { mountShareDialog } from './components/ShareDialog.js';
67bcfc6d 2026-03-26 Pavel Safronov21import { notifyMyTurn, clearNotification, registerServiceWorker, subscribeToPush, isNotifOptedOut, setNotifOptOut, unsubscribeFromPush } from './utils/notifications.js';
6a27222f 2026-03-26 Pavel Safronov22import speakerIconUrl from './assets/speaker.png';
23import muteIconUrl from './assets/mute.png';
4a3e918a 2026-03-26 Pavel Safronov24import urlIconUrl from './assets/url.png';
881b4413 2026-03-27 Pavel Safronov25import { getRandomWord, isValidAnswer } from './utils/wordList.js';
51cb47e7 2026-03-26 Pavel Safronov26import type { GameState, PlayerId, Round, SoloGameState, SoloRound, PendingNotification } from './types.js';
5d462205 2026-03-24 Pavel Safronov27// Waiting status helper
28// ---------------------------------------------------------------------------
29
30function createWaitingEl(text: string): HTMLElement {
31 const wrap = document.createElement('div');
32 wrap.className = 'status status--waiting';
33 const dot = document.createElement('span');
34 dot.className = 'status__live-dot';
35 dot.setAttribute('aria-hidden', 'true');
36 const msg = document.createElement('span');
37 msg.textContent = text;
38 wrap.appendChild(dot);
39 wrap.appendChild(msg);
40 return wrap;
41}
42
1c548168 2026-03-24 Pavel Safronov43// ---------------------------------------------------------------------------
44// Player identity — stored in localStorage, keyed to the gameId.
45//
46// Limitation: if a player opens the link on a new device they won't have a
47// stored role and will be assigned based on game state heuristics (see below).
48// ---------------------------------------------------------------------------
49
50const roleKey = (gameId: string) => `word-waltz:player:${gameId}`;
f038874d 2026-03-24 Pavel Safronov51const NAME_KEY = 'word-waltz:name';
52
53function getSavedName(): string { return localStorage.getItem(NAME_KEY) ?? ''; }
54function saveName(name: string): void { localStorage.setItem(NAME_KEY, name); }
1c548168 2026-03-24 Pavel Safronov55
56function getRole(gameId: string): PlayerId | null {
57 return localStorage.getItem(roleKey(gameId)) as PlayerId | null;
58}
59
60function setRole(gameId: string, role: PlayerId): void {
61 localStorage.setItem(roleKey(gameId), role);
62}
63
64// ---------------------------------------------------------------------------
65// State-machine helpers
66// ---------------------------------------------------------------------------
67
68/** The player who needs to pick a word for the next round. */
69function nextChallenger(state: GameState): PlayerId {
70 if (state.rounds.length === 0) return 'a';
71 const last = state.rounds[state.rounds.length - 1];
72 return last.challenger === 'a' ? 'b' : 'a';
73}
74
75/** The player who is solving the current round. */
76function currentSolver(state: GameState): PlayerId {
77 return state.rounds[state.currentRound].challenger === 'a' ? 'b' : 'a';
78}
79
f81da770 2026-03-24 Pavel Safronov80/** Returns true if it is currently this player's turn to act. */
81function isMyTurn(state: GameState, role: PlayerId): boolean {
82 if (state.players.b.name === '') return false; // waiting for opponent to join
83 if (state.turn === 'pick') return nextChallenger(state) === role;
84 return currentSolver(state) === role;
85}
86
560597e5 2026-03-24 Pavel Safronov87/** Human-readable status shown on the home screen game list. */
88function computeStatus(state: GameState, role: PlayerId): string {
89 if (state.players.b.name === '') return 'Waiting for opponent to join';
90 if (isMyTurn(state, role)) {
91 return state.turn === 'pick' ? 'Your turn to pick a word' : 'Your turn to solve';
92 }
93 return state.turn === 'pick' ? 'Opponent is picking a word' : 'Opponent is solving';
94}
95
96/** Navigate to a game by setting the URL fragment (triggers hashchange). */
97function navigateToGame(gameId: string, key: string): void {
98 const fragment = buildLink(gameId, key).split('#')[1];
99 if (fragment) window.location.hash = fragment;
100}
101
4a3e918a 2026-03-26 Pavel Safronov102// ---------------------------------------------------------------------------
103// Share button — shown in the header while a game is active
104// ---------------------------------------------------------------------------
105
106let shareModal: HTMLElement | null = null;
107
d3fd1d8a 2026-03-27 Pavel Safronov108function updateShareButton(): void {
4a3e918a 2026-03-26 Pavel Safronov109 const btn = document.getElementById('share-btn') as HTMLButtonElement | null;
110 if (!btn) return;
111
d3fd1d8a 2026-03-27 Pavel Safronov112 const { gameId, key } = parseLink();
113 if (!gameId || !key) {
4a3e918a 2026-03-26 Pavel Safronov114 btn.hidden = true;
115 btn.onclick = null;
116 return;
117 }
118
d3fd1d8a 2026-03-27 Pavel Safronov119 const inviteUrl = buildLink(gameId, key, 'b');
120
121 if (!btn.hidden && btn.querySelector('img')) {
122 // Already set up — just refresh the onclick with the current URL
123 btn.onclick = openShareModal(inviteUrl);
124 return;
125 }
126
4a3e918a 2026-03-26 Pavel Safronov127 btn.textContent = '';
128 const shareImg = document.createElement('img');
129 shareImg.src = urlIconUrl;
130 shareImg.alt = 'Share';
131 btn.appendChild(shareImg);
132 btn.hidden = false;
d3fd1d8a 2026-03-27 Pavel Safronov133 btn.onclick = openShareModal(inviteUrl);
134}
135
136function openShareModal(inviteUrl: string): () => void {
137 return () => {
4a3e918a 2026-03-26 Pavel Safronov138 if (shareModal) { shareModal.remove(); shareModal = null; return; }
139
140 const overlay = document.createElement('div');
141 overlay.className = 'share-modal';
142 shareModal = overlay;
143
144 const box = document.createElement('div');
145 box.className = 'share-modal__box';
146
147 const closeBtn = document.createElement('button');
148 closeBtn.className = 'share-modal__close';
149 closeBtn.textContent = '×';
150 closeBtn.setAttribute('aria-label', 'Close');
151 closeBtn.onclick = () => { overlay.remove(); shareModal = null; };
152 box.appendChild(closeBtn);
153
154 const inner = document.createElement('div');
155 box.appendChild(inner);
156 overlay.appendChild(box);
157 document.body.appendChild(overlay);
158
159 import('./components/ShareDialog.js').then(({ mountShareDialog }) => {
160 mountShareDialog(inner, { shareUrl: inviteUrl });
161 }).catch(console.error);
162
163 overlay.addEventListener('click', (e) => {
164 if (e.target === overlay) { overlay.remove(); shareModal = null; }
165 });
166 };
167}
168
1c548168 2026-03-24 Pavel Safronov169// ---------------------------------------------------------------------------
170// Firestore write helper
171// ---------------------------------------------------------------------------
172
51cb47e7 2026-03-26 Pavel Safronov173async function saveState(
174 gameId: string,
175 key: CryptoKey,
176 state: GameState,
177 pendingNotification?: PendingNotification | null,
178): Promise<void> {
1c548168 2026-03-24 Pavel Safronov179 const envelope = await encrypt(JSON.stringify(state), key);
51cb47e7 2026-03-26 Pavel Safronov180 await updateGame(gameId, envelope, pendingNotification);
1c548168 2026-03-24 Pavel Safronov181}
182
183// ---------------------------------------------------------------------------
184// View rendering
185// ---------------------------------------------------------------------------
186
187let cleanupView: (() => void) | null = null;
f038874d 2026-03-24 Pavel Safronov188let unsubscribeGame: (() => void) | null = null;
1c548168 2026-03-24 Pavel Safronov189
190function render(
191 app: HTMLElement,
192 state: GameState,
193 myRole: PlayerId,
194 gameId: string,
195 key: CryptoKey,
560597e5 2026-03-24 Pavel Safronov196 base64urlKey: string,
1c548168 2026-03-24 Pavel Safronov197 shareUrl: string,
198): void {
199 cleanupView?.();
200 cleanupView = null;
201 app.innerHTML = '';
202
560597e5 2026-03-24 Pavel Safronov203 // Keep the home-screen game list up to date with the latest state.
204 upsertStoredGame({
205 gameId,
206 key: base64urlKey,
207 role: myRole,
208 myName: state.players[myRole].name,
209 opponentName: (myRole === 'a' ? state.players.b : state.players.a).name,
210 status: computeStatus(state, myRole),
211 myTurn: isMyTurn(state, myRole),
212 createdAt: state.createdAt,
213 lastUpdated: Date.now(),
214 });
215
1c548168 2026-03-24 Pavel Safronov216 // Scoreboard is always visible
217 const scoreboardEl = document.createElement('div');
218 app.appendChild(scoreboardEl);
219 const cleanupScoreboard = mountScoreboard(scoreboardEl, {
220 players: state.players,
221 rounds: state.rounds,
bf4044de 2026-03-24 Pavel Safronov222 myRole,
4a3e918a 2026-03-26 Pavel Safronov223 turn: state.turn,
224 currentRound: state.currentRound,
bf4044de 2026-03-24 Pavel Safronov225 onNameChange: async (newName: string) => {
226 saveName(newName);
227 await saveState(gameId, key, {
228 ...state,
229 players: {
230 ...state.players,
231 [myRole]: { ...state.players[myRole], name: newName },
232 },
233 updatedAt: Date.now(),
234 });
235 },
1c548168 2026-03-24 Pavel Safronov236 });
237
238 const mainEl = document.createElement('div');
239 mainEl.className = 'main-view';
240 app.appendChild(mainEl);
241
242 let cleanupMain: (() => void) | null = null;
243
f038874d 2026-03-24 Pavel Safronov244 // Before the opponent has joined, always show the share dialog so Player A
245 // can send the invite link first. Once Player B sets their name (received
246 // via real-time subscription) the view re-renders into the normal flow.
247 if (state.players.b.name === '') {
248 const shareEl = document.createElement('div');
249 mainEl.appendChild(shareEl);
4a3e918a 2026-03-26 Pavel Safronov250 const inviteUrl = buildLink(gameId, base64urlKey, 'b');
251 cleanupMain = mountShareDialog(shareEl, { shareUrl: inviteUrl });
5d462205 2026-03-24 Pavel Safronov252 mainEl.appendChild(createWaitingEl('Share the link above with your opponent, then wait for them to join…'));
f038874d 2026-03-24 Pavel Safronov253 } else if (state.turn === 'pick') {
1c548168 2026-03-24 Pavel Safronov254 const challenger = nextChallenger(state);
255
256 if (challenger === myRole) {
51cb47e7 2026-03-26 Pavel Safronov257 const solver: PlayerId = myRole === 'a' ? 'b' : 'a';
1c548168 2026-03-24 Pavel Safronov258 cleanupMain = mountWordPicker(mainEl, {
259 onWordChosen: async (word: string) => {
260 const newRound: Round = {
261 challenger: myRole,
262 word,
263 guesses: [],
264 solved: false,
265 score: 0,
266 };
51cb47e7 2026-03-26 Pavel Safronov267 const notify: PendingNotification = {
268 player: solver,
269 message: `${state.players[myRole].name || 'Your opponent'} challenged you — your turn to solve!`,
270 gameUrl: shareUrl.split('#')[0],
271 };
1c548168 2026-03-24 Pavel Safronov272 await saveState(gameId, key, {
273 ...state,
274 rounds: [...state.rounds, newRound],
275 currentRound: state.rounds.length,
276 turn: 'solve',
277 updatedAt: Date.now(),
51cb47e7 2026-03-26 Pavel Safronov278 }, notify);
1c548168 2026-03-24 Pavel Safronov279 },
280 });
281 } else {
5d462205 2026-03-24 Pavel Safronov282 mainEl.appendChild(createWaitingEl('Waiting for your opponent to pick a word…'));
1c548168 2026-03-24 Pavel Safronov283 }
284 } else {
285 // turn === 'solve'
286 const solver = currentSolver(state);
287
288 if (solver === myRole) {
289 const round = state.rounds[state.currentRound];
e86661e4 2026-03-24 Pavel Safronov290 if (!round) {
291 // currentRound is temporarily out of bounds (e.g. between write
292 // and the real-time subscription delivering the new round).
293 mainEl.innerHTML = '<p class="status">Loading…</p>';
294 cleanupView = () => { cleanupScoreboard(); };
295 return;
296 }
1c548168 2026-03-24 Pavel Safronov297 cleanupMain = mountWordleBoard(mainEl, {
298 secretWord: round.word,
299 onRoundEnd: async (result) => {
300 const updatedRounds = state.rounds.map((r, i) =>
301 i === state.currentRound ? { ...r, ...result } : r,
302 );
303 await saveState(gameId, key, {
304 ...state,
305 rounds: updatedRounds,
306 players: {
307 ...state.players,
308 [myRole]: {
309 ...state.players[myRole],
310 score: state.players[myRole].score + result.score,
311 },
312 },
313 turn: 'pick',
314 updatedAt: Date.now(),
315 });
316 },
317 });
318 } else {
f038874d 2026-03-24 Pavel Safronov319 // Challenger waits while their opponent solves
5d462205 2026-03-24 Pavel Safronov320 mainEl.appendChild(createWaitingEl('Waiting for your opponent to solve the word…'));
1c548168 2026-03-24 Pavel Safronov321 }
322 }
323
324 cleanupView = () => {
325 cleanupScoreboard();
326 cleanupMain?.();
327 };
328}
329
8008bb36 2026-03-26 Pavel Safronov330// ---------------------------------------------------------------------------
331// Solo mode — purely local, no Firestore
332// ---------------------------------------------------------------------------
333
334const SOLO_KEY = 'word-waltz:solo';
335
336function loadSoloGame(): SoloGameState | null {
337 try {
338 const raw = localStorage.getItem(SOLO_KEY);
881b4413 2026-03-27 Pavel Safronov339 if (!raw) return null;
340 const state = JSON.parse(raw) as SoloGameState;
341 // Discard games created with an old word list
342 const currentWord = state.rounds[state.currentRound]?.word;
343 if (!currentWord || !isValidAnswer(currentWord)) {
344 localStorage.removeItem(SOLO_KEY);
345 return null;
346 }
347 return state;
8008bb36 2026-03-26 Pavel Safronov348 } catch {
349 return null;
350 }
351}
352
353function saveSoloGame(state: SoloGameState): void {
354 localStorage.setItem(SOLO_KEY, JSON.stringify(state));
355}
356
357function deleteSoloGame(): void {
358 localStorage.removeItem(SOLO_KEY);
359}
360
361function showSoloGame(app: HTMLElement): void {
362 unsubscribeGame?.();
363 unsubscribeGame = null;
364 cleanupView?.();
365 cleanupView = null;
366 app.innerHTML = '';
367
368 let state = loadSoloGame();
369 if (!state) {
370 const name = getSavedName();
371 const firstWord = getRandomWord();
372 const firstRound: SoloRound = { word: firstWord, guesses: [], solved: false, score: 0 };
373 state = {
374 playerName: name || 'You',
375 totalScore: 0,
376 rounds: [firstRound],
377 currentRound: 0,
378 createdAt: Date.now(),
379 updatedAt: Date.now(),
380 };
381 saveSoloGame(state);
382 }
383
384 function renderSolo(current: SoloGameState): void {
385 cleanupView?.();
386 cleanupView = null;
387 app.innerHTML = '';
388
389 const scoreboardEl = document.createElement('div');
390 app.appendChild(scoreboardEl);
391 const cleanupScoreboard = mountSoloScoreboard(scoreboardEl, {
392 playerName: current.playerName,
393 totalScore: current.totalScore,
394 rounds: current.rounds,
395 onNameChange: (newName: string) => {
396 saveName(newName);
397 const updated = { ...current, playerName: newName, updatedAt: Date.now() };
398 saveSoloGame(updated);
399 renderSolo(updated);
400 },
401 });
402
403 const mainEl = document.createElement('div');
404 mainEl.className = 'main-view';
405 app.appendChild(mainEl);
406
407 const round = current.rounds[current.currentRound];
408
409 const cleanupBoard = mountWordleBoard(mainEl, {
410 secretWord: round.word,
411 onRoundEnd: (result) => {
412 const updatedRounds: SoloRound[] = current.rounds.map((r, i) =>
413 i === current.currentRound ? { ...r, ...result } : r,
414 );
415 const newTotalScore = current.totalScore + result.score;
416 const nextWord = getRandomWord();
417 const nextRound: SoloRound = { word: nextWord, guesses: [], solved: false, score: 0 };
418 const updated: SoloGameState = {
419 ...current,
420 totalScore: newTotalScore,
421 rounds: [...updatedRounds, nextRound],
422 currentRound: updatedRounds.length,
423 updatedAt: Date.now(),
424 };
425 saveSoloGame(updated);
426 renderSolo(updated);
427 },
428 });
429
430 cleanupView = () => {
431 cleanupScoreboard();
432 cleanupBoard();
433 };
434 }
435
436 renderSolo(state);
437}
438
1c548168 2026-03-24 Pavel Safronov439// ---------------------------------------------------------------------------
560597e5 2026-03-24 Pavel Safronov440// Home screen — game list + new game form
1c548168 2026-03-24 Pavel Safronov441// ---------------------------------------------------------------------------
442
560597e5 2026-03-24 Pavel Safronov443function showHomeScreen(app: HTMLElement): void {
444 app.innerHTML = '';
445
446 const root = document.createElement('div');
447 root.className = 'home-screen';
448 app.appendChild(root);
449
8008bb36 2026-03-26 Pavel Safronov450 // --- Game list (multiplayer + solo merged) ---
560597e5 2026-03-24 Pavel Safronov451 const games = getStoredGames();
8008bb36 2026-03-26 Pavel Safronov452 const existingSolo = loadSoloGame();
453 const hasAnything = games.length > 0 || existingSolo !== null;
454
455 if (hasAnything) {
560597e5 2026-03-24 Pavel Safronov456 const listSection = document.createElement('div');
457 listSection.className = 'game-list';
458
459 const header = document.createElement('div');
460 header.className = 'game-list__header';
461
462 const h2 = document.createElement('h2');
463 h2.textContent = 'Your Games';
464 header.appendChild(h2);
465
466 const deleteAllBtn = document.createElement('button');
467 deleteAllBtn.className = 'game-list__delete-all';
468 deleteAllBtn.textContent = 'Delete all';
469 deleteAllBtn.addEventListener('click', () => {
470 if (!confirm('Delete all games? This cannot be undone.')) return;
471 const all = getStoredGames();
472 clearStoredGames();
8008bb36 2026-03-26 Pavel Safronov473 deleteSoloGame();
560597e5 2026-03-24 Pavel Safronov474 for (const g of all) {
475 deleteGame(g.gameId).catch((err: unknown) =>
476 console.warn('Firestore delete failed for', g.gameId, err));
477 }
478 showHomeScreen(app);
479 });
480 header.appendChild(deleteAllBtn);
481 listSection.appendChild(header);
482
483 const ul = document.createElement('ul');
484 ul.className = 'game-list__items';
485
8008bb36 2026-03-26 Pavel Safronov486 // --- Solo card (always first if it exists) ---
487 if (existingSolo) {
488 const completedCount = existingSolo.rounds.filter(r => r.guesses.length > 0).length;
489 const li = document.createElement('li');
490 li.className = 'game-card game-card--solo';
491
492 const body = document.createElement('div');
493 body.className = 'game-card__body';
494 body.setAttribute('role', 'button');
495 body.tabIndex = 0;
496 body.addEventListener('click', () => showSoloGame(app));
497 body.addEventListener('keydown', (e) => {
498 if (e.key === 'Enter' || e.key === ' ') showSoloGame(app);
499 });
500
501 const labelEl = document.createElement('span');
502 labelEl.className = 'game-card__opponent';
503 labelEl.textContent = 'Solo';
504 body.appendChild(labelEl);
505
506 const statusEl = document.createElement('span');
507 statusEl.className = 'game-card__status game-card__status--my-turn';
508 statusEl.textContent = completedCount === 0
509 ? 'New game — your turn'
510 : `Score: ${existingSolo.totalScore} · ${completedCount} round${completedCount === 1 ? '' : 's'}`;
511 body.appendChild(statusEl);
512
513 const delBtn = document.createElement('button');
514 delBtn.className = 'game-card__delete';
515 delBtn.type = 'button';
516 delBtn.setAttribute('aria-label', 'Delete solo game');
517 delBtn.textContent = '×';
518 delBtn.addEventListener('click', () => {
519 deleteSoloGame();
520 li.remove();
521 if (getStoredGames().length === 0) showHomeScreen(app);
522 });
523
524 li.appendChild(body);
525 li.appendChild(delBtn);
526 ul.appendChild(li);
527 }
528
560597e5 2026-03-24 Pavel Safronov529 for (const game of games) {
530 const li = document.createElement('li');
531 li.className = 'game-card';
532
533 const body = document.createElement('div');
534 body.className = 'game-card__body';
535 body.setAttribute('role', 'button');
536 body.tabIndex = 0;
537 body.addEventListener('click', () => navigateToGame(game.gameId, game.key));
538 body.addEventListener('keydown', (e) => {
539 if (e.key === 'Enter' || e.key === ' ') navigateToGame(game.gameId, game.key);
540 });
541
542 const opponentEl = document.createElement('span');
543 opponentEl.className = 'game-card__opponent';
544 opponentEl.textContent = game.opponentName ? `vs. ${game.opponentName}` : 'Waiting for opponent';
545 body.appendChild(opponentEl);
546
547 const statusEl = document.createElement('span');
548 statusEl.className = 'game-card__status' + (game.myTurn ? ' game-card__status--my-turn' : '');
549 statusEl.textContent = game.status;
550 body.appendChild(statusEl);
551
552 const delBtn = document.createElement('button');
553 delBtn.className = 'game-card__delete';
554 delBtn.type = 'button';
555 delBtn.setAttribute('aria-label', 'Delete game');
556 delBtn.textContent = '×';
557 delBtn.addEventListener('click', () => {
558 removeStoredGame(game.gameId);
559 deleteGame(game.gameId).catch((err: unknown) =>
560 console.warn('Firestore delete failed for', game.gameId, err));
561 li.remove();
8008bb36 2026-03-26 Pavel Safronov562 if (getStoredGames().length === 0 && !loadSoloGame()) showHomeScreen(app);
560597e5 2026-03-24 Pavel Safronov563 });
564
565 li.appendChild(body);
566 li.appendChild(delBtn);
567 ul.appendChild(li);
568 }
569
570 listSection.appendChild(ul);
571 root.appendChild(listSection);
572 }
573
574 // --- New game form ---
575 const newGameSection = document.createElement('div');
576 newGameSection.className = 'new-game';
577
578 const desc = document.createElement('p');
579 desc.textContent = 'Start a new game and share the link with a friend.';
580 newGameSection.appendChild(desc);
581
582 const form = document.createElement('form');
583 const label = document.createElement('label');
584 label.appendChild(document.createTextNode('Your name'));
585 const nameInput = document.createElement('input');
586 nameInput.type = 'text';
587 nameInput.maxLength = 20;
588 nameInput.required = true;
589 nameInput.autocomplete = 'off';
590 nameInput.value = getSavedName();
591 label.appendChild(nameInput);
592 form.appendChild(label);
593
594 const submitBtn = document.createElement('button');
595 submitBtn.type = 'submit';
596 submitBtn.textContent = 'Create game';
597 form.appendChild(submitBtn);
598 newGameSection.appendChild(form);
8008bb36 2026-03-26 Pavel Safronov599
600 const orSolo = document.createElement('p');
601 orSolo.className = 'new-game__or';
602 orSolo.textContent = 'or';
603 newGameSection.appendChild(orSolo);
604
605 const soloBtn = document.createElement('button');
606 soloBtn.type = 'button';
607 soloBtn.className = 'new-game__solo-btn';
608 soloBtn.textContent = 'Play Solo';
609 soloBtn.addEventListener('click', () => showSoloGame(app));
610 newGameSection.appendChild(soloBtn);
611
560597e5 2026-03-24 Pavel Safronov612 root.appendChild(newGameSection);
613
4a3e918a 2026-03-26 Pavel Safronov614 form.addEventListener('submit', (e) => {
1c548168 2026-03-24 Pavel Safronov615 e.preventDefault();
616 const name = nameInput.value.trim();
617 if (!name) return;
618
f038874d 2026-03-24 Pavel Safronov619 saveName(name);
1c548168 2026-03-24 Pavel Safronov620
4a3e918a 2026-03-26 Pavel Safronov621 // Step 2: pick a word before creating the game
622 newGameSection.innerHTML = '';
623 const pickDesc = document.createElement('p');
624 pickDesc.textContent = 'Pick a secret word to challenge your opponent with:';
625 newGameSection.appendChild(pickDesc);
1c548168 2026-03-24 Pavel Safronov626
4a3e918a 2026-03-26 Pavel Safronov627 const wordPickerEl = document.createElement('div');
628 newGameSection.appendChild(wordPickerEl);
1c548168 2026-03-24 Pavel Safronov629
4a3e918a 2026-03-26 Pavel Safronov630 mountWordPicker(wordPickerEl, {
631 onWordChosen: async (word: string) => {
632 wordPickerEl.innerHTML = '<p class="status">Creating game…</p>';
09447c68 2026-03-24 Pavel Safronov633
4a3e918a 2026-03-26 Pavel Safronov634 try {
635 const gameId = crypto.randomUUID();
636 const cryptoKey = await generateKey();
637 const base64urlKey = await exportKey(cryptoKey);
09447c68 2026-03-24 Pavel Safronov638
4a3e918a 2026-03-26 Pavel Safronov639 const now = Date.now();
640 const firstRound: Round = {
641 challenger: 'a',
642 word,
643 guesses: [],
644 solved: false,
645 score: 0,
646 };
647 const initialState: GameState = {
648 players: {
649 a: { name, score: 0 },
650 b: { name: '', score: 0 },
651 },
652 rounds: [firstRound],
653 currentRound: 0,
654 turn: 'solve',
655 createdAt: now,
656 updatedAt: now,
657 };
1c548168 2026-03-24 Pavel Safronov658
4a3e918a 2026-03-26 Pavel Safronov659 const envelope = await encrypt(JSON.stringify(initialState), cryptoKey);
660
661 // Race the Firestore write against an 8 s timeout so we surface
662 // errors (e.g. database not created, network issue) instead of
663 // hanging on "Creating game…" forever.
664 const timeout = new Promise<never>((_, reject) =>
665 setTimeout(() => reject(new Error('Timed out connecting to Firestore. Make sure your Firestore security rules allow read/write (see the Rules tab in the Firebase console).')), 8_000),
666 );
667 await Promise.race([createGame(gameId, envelope), timeout]);
668
669 setRole(gameId, 'a');
670 upsertStoredGame({
671 gameId,
672 key: base64urlKey,
673 role: 'a',
674 myName: name,
675 opponentName: '',
676 status: 'Waiting for opponent to join',
677 myTurn: false,
678 createdAt: initialState.createdAt,
679 lastUpdated: Date.now(),
680 });
681
682 // Navigate — key is in the fragment and is never sent to the server
683 navigateToGame(gameId, base64urlKey);
684 } catch (err) {
685 console.error('Game creation failed:', err);
686 const raw = err instanceof Error ? err.message : String(err);
687 const msg = raw.toLowerCase().includes('permission')
688 ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.'
689 : raw || 'Failed to create game. Please try again.';
690 showError(app, msg);
691 }
692 },
693 });
1c548168 2026-03-24 Pavel Safronov694 });
695}
696
697// ---------------------------------------------------------------------------
698// Player B name prompt
699// ---------------------------------------------------------------------------
700
701function promptPlayerBName(
702 app: HTMLElement,
703 onName: (name: string) => void,
704): void {
560597e5 2026-03-24 Pavel Safronov705 app.innerHTML = '';
706 const wrap = document.createElement('div');
707 wrap.className = 'new-game';
708 const p = document.createElement('p');
709 p.textContent = "You've been invited to play! Enter your name to join.";
710 wrap.appendChild(p);
711 const form = document.createElement('form');
712 const label = document.createElement('label');
713 label.appendChild(document.createTextNode('Your name'));
714 const nameInput = document.createElement('input');
715 nameInput.type = 'text';
716 nameInput.maxLength = 20;
717 nameInput.required = true;
718 nameInput.autocomplete = 'off';
719 nameInput.value = getSavedName();
720 label.appendChild(nameInput);
721 form.appendChild(label);
722 const btn = document.createElement('button');
723 btn.type = 'submit';
724 btn.textContent = 'Join game';
725 form.appendChild(btn);
726 wrap.appendChild(form);
727 app.appendChild(wrap);
728 form.addEventListener('submit', (e) => {
1c548168 2026-03-24 Pavel Safronov729 e.preventDefault();
560597e5 2026-03-24 Pavel Safronov730 const name = nameInput.value.trim();
f038874d 2026-03-24 Pavel Safronov731 if (name) { saveName(name); onName(name); }
1c548168 2026-03-24 Pavel Safronov732 });
733}
734
735// ---------------------------------------------------------------------------
736// Error display
737// ---------------------------------------------------------------------------
738
739function showError(app: HTMLElement, message: string): void {
e86661e4 2026-03-24 Pavel Safronov740 app.innerHTML = '';
741 const p = document.createElement('p');
742 p.className = 'error';
743 p.textContent = message;
744 app.appendChild(p);
1c548168 2026-03-24 Pavel Safronov745}
746
747// ---------------------------------------------------------------------------
748// Bootstrap
749// ---------------------------------------------------------------------------
750
751async function bootstrap(): Promise<void> {
752 const app = document.getElementById('app');
753 if (!app) throw new Error('#app element not found');
754
4a3e918a 2026-03-26 Pavel Safronov755 const { gameId, key, role: urlRole } = parseLink();
1c548168 2026-03-24 Pavel Safronov756
757 if (!gameId || !key) {
560597e5 2026-03-24 Pavel Safronov758 showHomeScreen(app);
1c548168 2026-03-24 Pavel Safronov759 return;
760 }
761
762 let cryptoKey: CryptoKey;
763 try {
764 cryptoKey = await importKey(key);
765 } catch {
766 showError(app, 'Invalid game link — the encryption key is malformed.');
767 return;
768 }
769
770 const envelope = await getGame(gameId);
771 if (!envelope) {
772 showError(app, 'Game not found — the link may be expired or invalid.');
773 return;
774 }
775
776 let state: GameState;
777 try {
778 state = JSON.parse(await decrypt(envelope, cryptoKey)) as GameState;
779 } catch {
780 showError(app, 'Could not decrypt game state — the link may be corrupted.');
781 return;
782 }
783
784 // Determine player role.
4a3e918a 2026-03-26 Pavel Safronov785 // Priority: localStorage (same device / browser) → role encoded in the URL
786 // → heuristic (B slot empty means this is B's first visit).
1c548168 2026-03-24 Pavel Safronov787 let myRole = getRole(gameId);
788 if (!myRole) {
4a3e918a 2026-03-26 Pavel Safronov789 if (urlRole === 'b' || (!urlRole && state.players.b.name === '')) {
1c548168 2026-03-24 Pavel Safronov790 // Player B's first visit — prompt for name then join
791 await new Promise<void>((resolve) => {
792 promptPlayerBName(app, async (name) => {
793 const updated: GameState = {
794 ...state,
795 players: { ...state.players, b: { name, score: 0 } },
796 updatedAt: Date.now(),
797 };
798 const updatedEnvelope = await encrypt(JSON.stringify(updated), cryptoKey);
51cb47e7 2026-03-26 Pavel Safronov799 const joinNotify: PendingNotification = {
800 player: 'a',
4a3e918a 2026-03-26 Pavel Safronov801 message: `${name} has joined and is solving your word!`,
51cb47e7 2026-03-26 Pavel Safronov802 gameUrl: buildLink(gameId, key).split('#')[0],
803 };
804 await updateGame(gameId, updatedEnvelope, joinNotify);
1c548168 2026-03-24 Pavel Safronov805 state = updated;
806 setRole(gameId, 'b');
807 myRole = 'b';
808 resolve();
809 });
810 });
811 } else {
4a3e918a 2026-03-26 Pavel Safronov812 // URL says role=a, or no URL role and B already joined → this is player A
813 // (e.g. creator rejoining on a new device).
1c548168 2026-03-24 Pavel Safronov814 setRole(gameId, 'a');
815 myRole = 'a';
816 }
817 }
818
819 const role = myRole as PlayerId;
820 const shareUrl = buildLink(gameId, key);
821
51cb47e7 2026-03-26 Pavel Safronov822 // Register the service worker and subscribe to push notifications.
823 // Fire-and-forget: push is a best-effort enhancement.
824 void (async () => {
825 await registerServiceWorker();
826 const subJson = await subscribeToPush();
827 if (subJson) {
828 storePushSubscription(gameId, role, subJson).catch((err: unknown) =>
829 console.warn('Failed to store push subscription:', err),
830 );
831 }
832 })();
833
560597e5 2026-03-24 Pavel Safronov834 render(app, state, role, gameId, cryptoKey, key, shareUrl);
1c548168 2026-03-24 Pavel Safronov835
f81da770 2026-03-24 Pavel Safronov836 // Track whether it was already my turn when we first loaded so we only
837 // notify on *transitions* (opponent finished), not on initial page open.
838 let wasMyTurn = isMyTurn(state, role);
839
f038874d 2026-03-24 Pavel Safronov840 unsubscribeGame?.();
841 unsubscribeGame = subscribeToGame(gameId, async (newEnvelope) => {
1c548168 2026-03-24 Pavel Safronov842 try {
843 const newState = JSON.parse(await decrypt(newEnvelope, cryptoKey)) as GameState;
f81da770 2026-03-24 Pavel Safronov844 const myTurnNow = isMyTurn(newState, role);
845 if (myTurnNow && !wasMyTurn) {
846 const msg = newState.turn === 'pick' ? "It's your turn to pick a word!" : "It's your turn to solve the word!";
847 notifyMyTurn(msg);
848 }
849 wasMyTurn = myTurnNow;
560597e5 2026-03-24 Pavel Safronov850 render(app, newState, role, gameId, cryptoKey, key, shareUrl);
e86661e4 2026-03-24 Pavel Safronov851 } catch (err) {
852 console.error('Failed to decrypt game update:', err);
853 showError(app, 'Could not decrypt the latest game update — the link may be corrupted.');
1c548168 2026-03-24 Pavel Safronov854 }
855 });
856}
857
f038874d 2026-03-24 Pavel Safronov858// Home link — clear the fragment and re-show the new-game screen without a
859// full page reload. This works regardless of the deployment base path.
860document.getElementById('home-link')?.addEventListener('click', (e) => {
861 e.preventDefault();
862 unsubscribeGame?.();
863 unsubscribeGame = null;
864 cleanupView?.();
865 cleanupView = null;
f81da770 2026-03-24 Pavel Safronov866 clearNotification();
f038874d 2026-03-24 Pavel Safronov867 history.replaceState(null, '', window.location.pathname);
d3fd1d8a 2026-03-27 Pavel Safronov868 updateShareButton();
f038874d 2026-03-24 Pavel Safronov869 const app = document.getElementById('app');
560597e5 2026-03-24 Pavel Safronov870 if (app) showHomeScreen(app);
f038874d 2026-03-24 Pavel Safronov871});
872
f81da770 2026-03-24 Pavel Safronov873// Clear the alert title as soon as the user focuses the tab
874document.addEventListener('visibilitychange', () => {
875 if (document.visibilityState === 'visible') clearNotification();
876});
877
d3fd1d8a 2026-03-27 Pavel Safronov878// Re-run bootstrap whenever the fragment changes.
f038874d 2026-03-24 Pavel Safronov879window.addEventListener('hashchange', () => {
880 unsubscribeGame?.();
881 unsubscribeGame = null;
882 cleanupView?.();
883 cleanupView = null;
d3fd1d8a 2026-03-27 Pavel Safronov884 updateShareButton();
f038874d 2026-03-24 Pavel Safronov885 bootstrap().catch((err: unknown) => {
886 console.error('Bootstrap failed:', err);
887 const app = document.getElementById('app');
888 if (app) showError(app, 'Something went wrong loading the game. Please refresh.');
889 });
890});
891
d3fd1d8a 2026-03-27 Pavel Safronov892updateShareButton();
1c548168 2026-03-24 Pavel Safronov893bootstrap().catch((err: unknown) => {
894 console.error('Bootstrap failed:', err);
895 const app = document.getElementById('app');
896 if (app) showError(app, 'Something went wrong loading the game. Please refresh.');
897});
898
42caeb85 2026-03-26 Pavel Safronov899// ---------------------------------------------------------------------------
900// Notification status button — always visible in the header
901// ---------------------------------------------------------------------------
902
903function initNotifButton(): void {
904 const btn = document.getElementById('notif-btn') as HTMLButtonElement | null;
905 if (!btn) return;
906
907 if (!('Notification' in window)) {
908 btn.hidden = true;
909 return;
910 }
911
6a27222f 2026-03-26 Pavel Safronov912 // Replace button content with the icon image
913 btn.textContent = '';
914 const img = document.createElement('img');
915 img.alt = 'Notifications';
916 btn.appendChild(img);
917
42caeb85 2026-03-26 Pavel Safronov918 function updateBtn(): void {
919 const perm = Notification.permission;
67bcfc6d 2026-03-26 Pavel Safronov920 const optedOut = isNotifOptedOut();
921 if (perm === 'granted' && !optedOut) {
6a27222f 2026-03-26 Pavel Safronov922 img.src = speakerIconUrl;
67bcfc6d 2026-03-26 Pavel Safronov923 btn!.title = 'Notifications enabled — click to disable';
42caeb85 2026-03-26 Pavel Safronov924 btn!.classList.add('header__notif-btn--on');
4a3e918a 2026-03-26 Pavel Safronov925 btn!.classList.remove('header__notif-btn--blocked');
42caeb85 2026-03-26 Pavel Safronov926 } else if (perm === 'denied') {
6a27222f 2026-03-26 Pavel Safronov927 img.src = muteIconUrl;
67bcfc6d 2026-03-26 Pavel Safronov928 btn!.title = 'Notifications blocked by browser';
4a3e918a 2026-03-26 Pavel Safronov929 btn!.classList.remove('header__notif-btn--on');
42caeb85 2026-03-26 Pavel Safronov930 btn!.classList.add('header__notif-btn--blocked');
67bcfc6d 2026-03-26 Pavel Safronov931 } else if (perm === 'granted' && optedOut) {
6a27222f 2026-03-26 Pavel Safronov932 img.src = muteIconUrl;
67bcfc6d 2026-03-26 Pavel Safronov933 btn!.title = 'Notifications disabled — click to enable';
934 btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
42caeb85 2026-03-26 Pavel Safronov935 } else {
4a3e918a 2026-03-26 Pavel Safronov936 img.src = muteIconUrl;
42caeb85 2026-03-26 Pavel Safronov937 btn!.title = 'Enable notifications';
4a3e918a 2026-03-26 Pavel Safronov938 btn!.classList.remove('header__notif-btn--on', 'header__notif-btn--blocked');
42caeb85 2026-03-26 Pavel Safronov939 }
940 }
941
942 updateBtn();
943
944 btn.addEventListener('click', async () => {
945 const perm = Notification.permission;
946
947 if (perm === 'denied') {
948 alert("Notifications are blocked. To enable them, click the lock/info icon in your browser's address bar, find Notifications, and set it to Allow.");
949 return;
950 }
951
952 if (perm === 'granted') {
67bcfc6d 2026-03-26 Pavel Safronov953 if (isNotifOptedOut()) {
954 // Re-enable
955 setNotifOptOut(false);
956 updateBtn();
957 await subscribeToPush();
958 } else {
959 // Disable
960 setNotifOptOut(true);
961 updateBtn();
962 await unsubscribeFromPush();
963 }
42caeb85 2026-03-26 Pavel Safronov964 return;
965 }
966
967 // 'default' — request permission
968 await registerServiceWorker();
969 const result = await Notification.requestPermission();
67bcfc6d 2026-03-26 Pavel Safronov970 setNotifOptOut(false);
42caeb85 2026-03-26 Pavel Safronov971 updateBtn();
972 if (result === 'granted') {
67bcfc6d 2026-03-26 Pavel Safronov973 const reg = await registerServiceWorker();
974 if (reg) {
975 await reg.showNotification('Word Waltz', {
976 body: "Notifications are working! You'll be alerted when it's your turn.",
977 icon: `${import.meta.env.BASE_URL}apple-touch-icon.png`,
978 tag: 'word-waltz-test',
979 });
980 }
42caeb85 2026-03-26 Pavel Safronov981 }
982 });
983}
984
985initNotifButton();
986