fuzzyworld

word-waltz / commit

adding support for multiple games

560597e5797fd7f43467eb04381de92683b66158 · Pavel Safronov · 2026-03-24 13:57:36 -0700 · parents b249163d19d9

adding support for multiple games
 src/firebase/gameService.ts |   7 +-
 src/main.ts                 | 234 ++++++++++++++++++++++++++++++++++++--------
 src/styles/main.css         | 115 ++++++++++++++++++++++
 src/utils/gameRegistry.ts   |  49 ++++++++++
 4 files changed, 362 insertions(+), 43 deletions(-)

diff --git a/src/firebase/gameService.ts b/src/firebase/gameService.ts
index 888ab6c..e987d6f 100644
--- a/src/firebase/gameService.ts
+++ b/src/firebase/gameService.ts
@@ -13,7 +13,7 @@
  * responsibility of the layer above (main.ts / route handlers).
  */
 
-import { doc, getDoc, setDoc, updateDoc, onSnapshot } from 'firebase/firestore';
+import { doc, getDoc, setDoc, updateDoc, deleteDoc, onSnapshot } from 'firebase/firestore';
 import { db } from './config.js';
 import type { Encrypted, EncryptedEnvelope } from '../types.js';
 
@@ -48,3 +48,8 @@ export function subscribeToGame(
   });
 }
 
+/** Permanently delete a game document from Firestore. */
+export async function deleteGame(gameId: string): Promise<void> {
+  await deleteDoc(doc(db, 'games', gameId));
+}
+
diff --git a/src/main.ts b/src/main.ts
index 90d6af8..8f2fc35 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -8,9 +8,11 @@
  *  - Subscribe to real-time Firestore updates and re-render on change
  */
 
-import { parseLink, buildLink } from './utils/shareLink.js';
+import { parseLink } from './utils/shareLink.js';
 import { generateKey, exportKey, importKey, encrypt, decrypt } from './crypto/encryption.js';
-import { createGame, getGame, updateGame, subscribeToGame } from './firebase/gameService.js';
+import { createGame, getGame, updateGame, subscribeToGame, deleteGame } from './firebase/gameService.js';
+import { getStoredGames, upsertStoredGame, removeStoredGame, clearStoredGames } from './utils/gameRegistry.js';
+import { buildLink } from './utils/shareLink.js';
 import { mountWordPicker } from './components/WordPicker.js';
 import { mountWordleBoard } from './components/WordleBoard.js';
 import { mountScoreboard } from './components/Scoreboard.js';
@@ -79,6 +81,21 @@ function isMyTurn(state: GameState, role: PlayerId): boolean {
     return currentSolver(state) === role;
 }
 
+/** Human-readable status shown on the home screen game list. */
+function computeStatus(state: GameState, role: PlayerId): string {
+    if (state.players.b.name === '') return 'Waiting for opponent to join';
+    if (isMyTurn(state, role)) {
+        return state.turn === 'pick' ? 'Your turn to pick a word' : 'Your turn to solve';
+    }
+    return state.turn === 'pick' ? 'Opponent is picking a word' : 'Opponent is solving';
+}
+
+/** Navigate to a game by setting the URL fragment (triggers hashchange). */
+function navigateToGame(gameId: string, key: string): void {
+    const fragment = buildLink(gameId, key).split('#')[1];
+    if (fragment) window.location.hash = fragment;
+}
+
 // ---------------------------------------------------------------------------
 // Firestore write helper
 // ---------------------------------------------------------------------------
@@ -101,12 +118,26 @@ function render(
     myRole: PlayerId,
     gameId: string,
     key: CryptoKey,
+    base64urlKey: string,
     shareUrl: string,
 ): void {
     cleanupView?.();
     cleanupView = null;
     app.innerHTML = '';
 
+    // Keep the home-screen game list up to date with the latest state.
+    upsertStoredGame({
+        gameId,
+        key: base64urlKey,
+        role: myRole,
+        myName: state.players[myRole].name,
+        opponentName: (myRole === 'a' ? state.players.b : state.players.a).name,
+        status: computeStatus(state, myRole),
+        myTurn: isMyTurn(state, myRole),
+        createdAt: state.createdAt,
+        lastUpdated: Date.now(),
+    });
+
     // Scoreboard is always visible
     const scoreboardEl = document.createElement('div');
     app.appendChild(scoreboardEl);
@@ -213,31 +244,128 @@ function render(
 }
 
 // ---------------------------------------------------------------------------
-// New game creation screen
+// Home screen — game list + new game form
 // ---------------------------------------------------------------------------
 
-function showNewGameScreen(app: HTMLElement): void {
-    app.innerHTML = `
-    <div class="new-game">
-      <p>Start a new game and share the link with a friend.</p>
-      <form id="new-game-form">
-        <label>Your name
-          <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" />
-        </label>
-        <button type="submit">Create game</button>
-      </form>
-    </div>
-  `;
-
-    app.querySelector('#new-game-form')!.addEventListener('submit', async (e) => {
+function showHomeScreen(app: HTMLElement): void {
+    app.innerHTML = '';
+
+    const root = document.createElement('div');
+    root.className = 'home-screen';
+    app.appendChild(root);
+
+    // --- Existing game list ---
+    const games = getStoredGames();
+    if (games.length > 0) {
+        const listSection = document.createElement('div');
+        listSection.className = 'game-list';
+
+        const header = document.createElement('div');
+        header.className = 'game-list__header';
+
+        const h2 = document.createElement('h2');
+        h2.textContent = 'Your Games';
+        header.appendChild(h2);
+
+        const deleteAllBtn = document.createElement('button');
+        deleteAllBtn.className = 'game-list__delete-all';
+        deleteAllBtn.textContent = 'Delete all';
+        deleteAllBtn.addEventListener('click', () => {
+            if (!confirm('Delete all games? This cannot be undone.')) return;
+            const all = getStoredGames();
+            clearStoredGames();
+            for (const g of all) {
+                deleteGame(g.gameId).catch((err: unknown) =>
+                    console.warn('Firestore delete failed for', g.gameId, err));
+            }
+            showHomeScreen(app);
+        });
+        header.appendChild(deleteAllBtn);
+        listSection.appendChild(header);
+
+        const ul = document.createElement('ul');
+        ul.className = 'game-list__items';
+
+        for (const game of games) {
+            const li = document.createElement('li');
+            li.className = 'game-card';
+
+            const body = document.createElement('div');
+            body.className = 'game-card__body';
+            body.setAttribute('role', 'button');
+            body.tabIndex = 0;
+            body.addEventListener('click', () => navigateToGame(game.gameId, game.key));
+            body.addEventListener('keydown', (e) => {
+                if (e.key === 'Enter' || e.key === ' ') navigateToGame(game.gameId, game.key);
+            });
+
+            const opponentEl = document.createElement('span');
+            opponentEl.className = 'game-card__opponent';
+            opponentEl.textContent = game.opponentName ? `vs. ${game.opponentName}` : 'Waiting for opponent';
+            body.appendChild(opponentEl);
+
+            const statusEl = document.createElement('span');
+            statusEl.className = 'game-card__status' + (game.myTurn ? ' game-card__status--my-turn' : '');
+            statusEl.textContent = game.status;
+            body.appendChild(statusEl);
+
+            const delBtn = document.createElement('button');
+            delBtn.className = 'game-card__delete';
+            delBtn.type = 'button';
+            delBtn.setAttribute('aria-label', 'Delete game');
+            delBtn.textContent = '×';
+            delBtn.addEventListener('click', () => {
+                removeStoredGame(game.gameId);
+                deleteGame(game.gameId).catch((err: unknown) =>
+                    console.warn('Firestore delete failed for', game.gameId, err));
+                li.remove();
+                if (getStoredGames().length === 0) showHomeScreen(app);
+            });
+
+            li.appendChild(body);
+            li.appendChild(delBtn);
+            ul.appendChild(li);
+        }
+
+        listSection.appendChild(ul);
+        root.appendChild(listSection);
+    }
+
+    // --- New game form ---
+    const newGameSection = document.createElement('div');
+    newGameSection.className = 'new-game';
+
+    const desc = document.createElement('p');
+    desc.textContent = 'Start a new game and share the link with a friend.';
+    newGameSection.appendChild(desc);
+
+    const form = document.createElement('form');
+
+    const label = document.createElement('label');
+    label.appendChild(document.createTextNode('Your name'));
+    const nameInput = document.createElement('input');
+    nameInput.type = 'text';
+    nameInput.maxLength = 20;
+    nameInput.required = true;
+    nameInput.autocomplete = 'off';
+    nameInput.value = getSavedName();
+    label.appendChild(nameInput);
+    form.appendChild(label);
+
+    const submitBtn = document.createElement('button');
+    submitBtn.type = 'submit';
+    submitBtn.textContent = 'Create game';
+    form.appendChild(submitBtn);
+    newGameSection.appendChild(form);
+    root.appendChild(newGameSection);
+
+    form.addEventListener('submit', async (e) => {
         e.preventDefault();
-        const nameInput = app.querySelector<HTMLInputElement>('#player-name')!;
         const name = nameInput.value.trim();
         if (!name) return;
 
-        const btn = app.querySelector<HTMLButtonElement>('button')!;
-        btn.disabled = true;
-        btn.textContent = 'Creating…';
+        submitBtn.disabled = true;
+        submitBtn.textContent = 'Creating…';
         saveName(name);
 
         try {
@@ -268,14 +396,24 @@ function showNewGameScreen(app: HTMLElement): void {
             await Promise.race([createGame(gameId, envelope), timeout]);
 
             setRole(gameId, 'a');
+            upsertStoredGame({
+                gameId,
+                key: base64urlKey,
+                role: 'a',
+                myName: name,
+                opponentName: '',
+                status: 'Waiting for opponent to join',
+                myTurn: false,
+                createdAt: initialState.createdAt,
+                lastUpdated: Date.now(),
+            });
 
             // Navigate — key is in the fragment and is never sent to the server
-            window.location.href = buildLink(gameId, base64urlKey);
+            navigateToGame(gameId, base64urlKey);
         } catch (err) {
             console.error('Game creation failed:', err);
-            btn.disabled = false;
-            btn.textContent = 'Create game';
-            // Surface Firestore permission-denied errors clearly
+            submitBtn.disabled = false;
+            submitBtn.textContent = 'Create game';
             const raw = err instanceof Error ? err.message : String(err);
             const msg = raw.toLowerCase().includes('permission')
                 ? 'Permission denied by Firestore. Open the Firebase console → Firestore → Rules and set: allow read, write: if true; then publish.'
@@ -293,20 +431,32 @@ function promptPlayerBName(
     app: HTMLElement,
     onName: (name: string) => void,
 ): void {
-    app.innerHTML = `
-    <div class="new-game">
-      <p>You've been invited to play! Enter your name to join.</p>
-      <form id="join-form">
-        <label>Your name
-          <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" />
-        </label>
-        <button type="submit">Join game</button>
-      </form>
-    </div>
-  `;
-    app.querySelector('#join-form')!.addEventListener('submit', (e) => {
+    app.innerHTML = '';
+    const wrap = document.createElement('div');
+    wrap.className = 'new-game';
+    const p = document.createElement('p');
+    p.textContent = "You've been invited to play! Enter your name to join.";
+    wrap.appendChild(p);
+    const form = document.createElement('form');
+    const label = document.createElement('label');
+    label.appendChild(document.createTextNode('Your name'));
+    const nameInput = document.createElement('input');
+    nameInput.type = 'text';
+    nameInput.maxLength = 20;
+    nameInput.required = true;
+    nameInput.autocomplete = 'off';
+    nameInput.value = getSavedName();
+    label.appendChild(nameInput);
+    form.appendChild(label);
+    const btn = document.createElement('button');
+    btn.type = 'submit';
+    btn.textContent = 'Join game';
+    form.appendChild(btn);
+    wrap.appendChild(form);
+    app.appendChild(wrap);
+    form.addEventListener('submit', (e) => {
         e.preventDefault();
-        const name = app.querySelector<HTMLInputElement>('#player-name')!.value.trim();
+        const name = nameInput.value.trim();
         if (name) { saveName(name); onName(name); }
     });
 }
@@ -334,7 +484,7 @@ async function bootstrap(): Promise<void> {
     const { gameId, key } = parseLink();
 
     if (!gameId || !key) {
-        showNewGameScreen(app);
+        showHomeScreen(app);
         return;
     }
 
@@ -391,7 +541,7 @@ async function bootstrap(): Promise<void> {
     const role = myRole as PlayerId;
     const shareUrl = buildLink(gameId, key);
 
-    render(app, state, role, gameId, cryptoKey, shareUrl);
+    render(app, state, role, gameId, cryptoKey, key, shareUrl);
 
     // Track whether it was already my turn when we first loaded so we only
     // notify on *transitions* (opponent finished), not on initial page open.
@@ -407,7 +557,7 @@ async function bootstrap(): Promise<void> {
                 notifyMyTurn(msg);
             }
             wasMyTurn = myTurnNow;
-            render(app, newState, role, gameId, cryptoKey, shareUrl);
+            render(app, newState, role, gameId, cryptoKey, key, shareUrl);
         } catch (err) {
             console.error('Failed to decrypt game update:', err);
             showError(app, 'Could not decrypt the latest game update — the link may be corrupted.');
@@ -426,7 +576,7 @@ document.getElementById('home-link')?.addEventListener('click', (e) => {
     clearNotification();
     history.replaceState(null, '', window.location.pathname);
     const app = document.getElementById('app');
-    if (app) showNewGameScreen(app);
+    if (app) showHomeScreen(app);
 });
 
 // Clear the alert title as soon as the user focuses the tab
diff --git a/src/styles/main.css b/src/styles/main.css
index e19f3d2..346e0a7 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -594,6 +594,121 @@ body:has(.word-picker) .main-view {
     padding-top: 12px;
 }
 
+/* Home screen is the only scrollable view. */
+body:has(.home-screen) #app {
+    overflow-y: auto;
+}
+
+/* --- Home screen ------------------------------------------ */
+.home-screen {
+    display: flex;
+    flex-direction: column;
+    gap: 20px;
+    width: 100%;
+    padding-bottom: 24px;
+}
+
+.game-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    width: 100%;
+}
+
+.game-list__header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+.game-list__header h2 {
+    font-size: 0.78rem;
+    font-weight: 700;
+    text-transform: uppercase;
+    letter-spacing: 0.08em;
+    color: var(--color-muted);
+}
+
+.game-list__delete-all {
+    font-size: 0.8rem;
+    color: var(--color-muted);
+    background: none;
+    border: none;
+    cursor: pointer;
+    padding: 4px 0;
+}
+
+.game-list__delete-all:hover {
+    color: var(--color-error);
+}
+
+.game-list__items {
+    list-style: none;
+    display: flex;
+    flex-direction: column;
+    gap: 6px;
+}
+
+.game-card {
+    display: flex;
+    align-items: stretch;
+    background: var(--color-surface);
+    border: 1px solid var(--color-border);
+    border-radius: var(--radius);
+    overflow: hidden;
+}
+
+.game-card__body {
+    flex: 1;
+    padding: 12px 16px;
+    cursor: pointer;
+    display: flex;
+    flex-direction: column;
+    gap: 3px;
+    min-width: 0;
+    outline: none;
+}
+
+.game-card__body:hover,
+.game-card__body:focus-visible {
+    background: rgba(0, 0, 0, 0.03);
+}
+
+.game-card__opponent {
+    font-weight: 600;
+    font-size: 0.95rem;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+}
+
+.game-card__status {
+    font-size: 0.8rem;
+    color: var(--color-muted);
+}
+
+.game-card__status--my-turn {
+    color: var(--color-correct);
+    font-weight: 600;
+}
+
+.game-card__delete {
+    padding: 0 16px;
+    background: none;
+    border: none;
+    border-left: 1px solid var(--color-border);
+    cursor: pointer;
+    color: var(--color-muted);
+    font-size: 1.3rem;
+    line-height: 1;
+    flex-shrink: 0;
+}
+
+.game-card__delete:hover {
+    color: var(--color-error);
+    background: rgba(192, 57, 43, 0.05);
+}
+
 .site-footer a:hover {
     text-decoration: underline;
 }
\ No newline at end of file
diff --git a/src/utils/gameRegistry.ts b/src/utils/gameRegistry.ts
new file mode 100644
index 0000000..540524f
--- /dev/null
+++ b/src/utils/gameRegistry.ts
@@ -0,0 +1,49 @@
+/**
+ * gameRegistry.ts — persists the list of games this device has participated in.
+ *
+ * Each entry stores enough to reconstruct the share URL (gameId + key) and to
+ * display a summary on the home screen without a round-trip to Firestore.
+ * The encryption key is stored in localStorage (local to this device only) —
+ * it is never sent to any server, preserving the same threat model as the URL
+ * fragment approach.
+ */
+
+import type { PlayerId } from '../types.js';
+
+export interface StoredGame {
+    gameId: string;
+    key: string;        // base64url AES-GCM key
+    role: PlayerId;
+    myName: string;
+    opponentName: string;  // '' if opponent hasn't joined yet
+    status: string;        // last-known human-readable status, e.g. "Your turn to solve"
+    myTurn: boolean;       // whether it is currently this player's turn
+    createdAt: number;
+    lastUpdated: number;
+}
+
+const REGISTRY_KEY = 'word-waltz:games';
+
+export function getStoredGames(): StoredGame[] {
+    try {
+        return JSON.parse(localStorage.getItem(REGISTRY_KEY) ?? '[]') as StoredGame[];
+    } catch {
+        return [];
+    }
+}
+
+/** Insert or update a game entry (matched by gameId). Most-recent first. */
+export function upsertStoredGame(game: StoredGame): void {
+    const existing = getStoredGames().filter(g => g.gameId !== game.gameId);
+    existing.unshift(game);
+    localStorage.setItem(REGISTRY_KEY, JSON.stringify(existing));
+}
+
+export function removeStoredGame(gameId: string): void {
+    const games = getStoredGames().filter(g => g.gameId !== gameId);
+    localStorage.setItem(REGISTRY_KEY, JSON.stringify(games));
+}
+
+export function clearStoredGames(): void {
+    localStorage.removeItem(REGISTRY_KEY);
+}