fuzzyworld

word-waltz / commit

got things mostly working

f038874d96f6c7a0d89132e01a2fdc2022eb67d0 · Pavel Safronov · 2026-03-23 18:06:07 -0700 · parents 09447c6803ac

got things mostly working
 firebase.json                |  5 ++++
 firestore.rules              | 11 +++++++
 src/components/Scoreboard.ts |  4 +--
 src/index.html               |  5 +++-
 src/main.ts                  | 68 +++++++++++++++++++++++++++++++++++++-------
 src/styles/main.css          | 24 ++++++++++++++--
 6 files changed, 100 insertions(+), 17 deletions(-)

diff --git a/firebase.json b/firebase.json
new file mode 100644
index 0000000..201ec0a
--- /dev/null
+++ b/firebase.json
@@ -0,0 +1,5 @@
+{
+    "firestore": {
+        "rules": "firestore.rules"
+    }
+}
\ No newline at end of file
diff --git a/firestore.rules b/firestore.rules
new file mode 100644
index 0000000..b0711d4
--- /dev/null
+++ b/firestore.rules
@@ -0,0 +1,11 @@
+rules_version = '2';
+service cloud.firestore {
+  match /databases/{database}/documents {
+    match /games/{gameId} {
+      // Confidentiality is enforced by client-side AES-GCM-256 encryption.
+      // The encryption key lives only in the URL fragment and is never sent
+      // to the server, so Firestore only ever sees opaque ciphertext.
+      allow read, write: if true;
+    }
+  }
+}
diff --git a/src/components/Scoreboard.ts b/src/components/Scoreboard.ts
index 05c28d3..eb752f1 100644
--- a/src/components/Scoreboard.ts
+++ b/src/components/Scoreboard.ts
@@ -33,8 +33,8 @@ export function mountScoreboard(container: HTMLElement, props: ScoreboardProps):
     const solver: PlayerId = r.challenger === 'a' ? 'b' : 'a';
     const solverName = players[solver].name || (solver === 'a' ? 'Player A' : 'Player B');
     const result = r.solved
-      ? `solved in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})`
-      : `failed (the word was ${r.word})`;
+      ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})`
+      : `failed to solve "${r.word}"`;
     return `<li class="scoreboard__round">${escHtml(solverName)}: ${escHtml(result)}</li>`;
   }).join('');
 
diff --git a/src/index.html b/src/index.html
index c33cb03..4d58285 100644
--- a/src/index.html
+++ b/src/index.html
@@ -14,7 +14,10 @@
 
 <body>
     <header>
-        <h1>Word Waltz</h1>
+        <a id="home-link" href="#" aria-label="Word Waltz — back to start">
+            <img src="./assets/logo.png" alt="" class="header__logo" />
+            <span class="header__title">Word Waltz</span>
+        </a>
     </header>
     <div id="app"></div>
     <script type="module" src="./main.ts"></script>
diff --git a/src/main.ts b/src/main.ts
index 827cb65..ef45c94 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -25,6 +25,10 @@ import type { GameState, PlayerId, Round } from './types.js';
 // ---------------------------------------------------------------------------
 
 const roleKey = (gameId: string) => `word-waltz:player:${gameId}`;
+const NAME_KEY = 'word-waltz:name';
+
+function getSavedName(): string { return localStorage.getItem(NAME_KEY) ?? ''; }
+function saveName(name: string): void { localStorage.setItem(NAME_KEY, name); }
 
 function getRole(gameId: string): PlayerId | null {
     return localStorage.getItem(roleKey(gameId)) as PlayerId | null;
@@ -64,6 +68,7 @@ async function saveState(gameId: string, key: CryptoKey, state: GameState): Prom
 // ---------------------------------------------------------------------------
 
 let cleanupView: (() => void) | null = null;
+let unsubscribeGame: (() => void) | null = null;
 
 function render(
     app: HTMLElement,
@@ -91,7 +96,18 @@ function render(
 
     let cleanupMain: (() => void) | null = null;
 
-    if (state.turn === 'pick') {
+    // Before the opponent has joined, always show the share dialog so Player A
+    // can send the invite link first. Once Player B sets their name (received
+    // via real-time subscription) the view re-renders into the normal flow.
+    if (state.players.b.name === '') {
+        const shareEl = document.createElement('div');
+        mainEl.appendChild(shareEl);
+        cleanupMain = mountShareDialog(shareEl, { shareUrl });
+        const waiting = document.createElement('p');
+        waiting.className = 'status';
+        waiting.textContent = 'Share the link above with your opponent, then wait for them to join…';
+        mainEl.appendChild(waiting);
+    } else if (state.turn === 'pick') {
         const challenger = nextChallenger(state);
 
         if (challenger === myRole) {
@@ -144,10 +160,7 @@ function render(
                 },
             });
         } else {
-            // Challenger waits: show the share link in case opponent hasn't joined yet
-            const shareEl = document.createElement('div');
-            mainEl.appendChild(shareEl);
-            cleanupMain = mountShareDialog(shareEl, { shareUrl });
+            // Challenger waits while their opponent solves
             const waiting = document.createElement('p');
             waiting.className = 'status';
             waiting.textContent = 'Waiting for your opponent to solve the word…';
@@ -171,7 +184,7 @@ function showNewGameScreen(app: HTMLElement): void {
       <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" />
+          <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" />
         </label>
         <button type="submit">Create game</button>
       </form>
@@ -187,6 +200,7 @@ function showNewGameScreen(app: HTMLElement): void {
         const btn = app.querySelector<HTMLButtonElement>('button')!;
         btn.disabled = true;
         btn.textContent = 'Creating…';
+        saveName(name);
 
         try {
             const gameId = crypto.randomUUID();
@@ -211,7 +225,7 @@ function showNewGameScreen(app: HTMLElement): void {
             // errors (e.g. database not created, network issue) instead of
             // hanging on "Creating…" forever.
             const timeout = new Promise<never>((_, reject) =>
-                setTimeout(() => reject(new Error('Firestore write timed out — check that the Firestore database exists in the Firebase console and that security rules allow writes.')), 10_000),
+                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),
             );
             await Promise.race([createGame(gameId, envelope), timeout]);
 
@@ -223,7 +237,11 @@ function showNewGameScreen(app: HTMLElement): void {
             console.error('Game creation failed:', err);
             btn.disabled = false;
             btn.textContent = 'Create game';
-            const msg = err instanceof Error ? err.message : 'Failed to create game. Please try again.';
+            // Surface Firestore permission-denied errors clearly
+            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.'
+                : raw || 'Failed to create game. Please try again.';
             showError(app, msg);
         }
     });
@@ -242,7 +260,7 @@ function promptPlayerBName(
       <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" />
+          <input type="text" id="player-name" maxlength="20" required autocomplete="off" value="${getSavedName()}" />
         </label>
         <button type="submit">Join game</button>
       </form>
@@ -251,7 +269,7 @@ function promptPlayerBName(
     app.querySelector('#join-form')!.addEventListener('submit', (e) => {
         e.preventDefault();
         const name = app.querySelector<HTMLInputElement>('#player-name')!.value.trim();
-        if (name) onName(name);
+        if (name) { saveName(name); onName(name); }
     });
 }
 
@@ -333,7 +351,8 @@ async function bootstrap(): Promise<void> {
 
     render(app, state, role, gameId, cryptoKey, shareUrl);
 
-    subscribeToGame(gameId, async (newEnvelope) => {
+    unsubscribeGame?.();
+    unsubscribeGame = subscribeToGame(gameId, async (newEnvelope) => {
         try {
             const newState = JSON.parse(await decrypt(newEnvelope, cryptoKey)) as GameState;
             render(app, newState, role, gameId, cryptoKey, shareUrl);
@@ -343,6 +362,33 @@ async function bootstrap(): Promise<void> {
     });
 }
 
+// Home link — clear the fragment and re-show the new-game screen without a
+// full page reload. This works regardless of the deployment base path.
+document.getElementById('home-link')?.addEventListener('click', (e) => {
+    e.preventDefault();
+    unsubscribeGame?.();
+    unsubscribeGame = null;
+    cleanupView?.();
+    cleanupView = null;
+    history.replaceState(null, '', window.location.pathname);
+    const app = document.getElementById('app');
+    if (app) showNewGameScreen(app);
+});
+
+// Re-run bootstrap whenever the fragment changes (e.g. after game creation
+// navigates to the game URL — a hash-only change doesn't reload the page).
+window.addEventListener('hashchange', () => {
+    unsubscribeGame?.();
+    unsubscribeGame = null;
+    cleanupView?.();
+    cleanupView = null;
+    bootstrap().catch((err: unknown) => {
+        console.error('Bootstrap failed:', err);
+        const app = document.getElementById('app');
+        if (app) showError(app, 'Something went wrong loading the game. Please refresh.');
+    });
+});
+
 bootstrap().catch((err: unknown) => {
     console.error('Bootstrap failed:', err);
     const app = document.getElementById('app');
diff --git a/src/styles/main.css b/src/styles/main.css
index 5a6be04..4acf2fa 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -50,13 +50,31 @@ body {
 header {
     display: flex;
     align-items: center;
-    gap: 12px;
-    padding: 12px 20px;
+    padding: 10px 20px;
     border-bottom: 1px solid var(--color-border);
     background: var(--color-surface);
 }
 
-header h1 {
+#home-link {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    text-decoration: none;
+    color: inherit;
+}
+
+#home-link:hover .header__title {
+    text-decoration: underline;
+}
+
+.header__logo {
+    height: 40px;
+    width: 40px;
+    object-fit: contain;
+    border-radius: 6px;
+}
+
+.header__title {
     font-size: 1.4rem;
     font-weight: 700;
     letter-spacing: 0.05em;