fuzzyworld

word-waltz / commit

added solo mode

8008bb36ad7e7026eecfd18c1956baf39b0d31b2 · Pavel Safronov · 2026-03-25 21:34:44 -0700 · parents 560597e5797f

added solo mode
 src/components/SoloScoreboard.ts | 134 ++++++++++++++++++++++++++++++
 src/components/WordleBoard.ts    |  17 ++++
 src/main.ts                      | 172 +++++++++++++++++++++++++++++++++++++--
 src/styles/main.css              |  44 ++++++++++
 src/types.ts                     |  21 +++++
 src/utils/wordList.ts            |   7 ++
 6 files changed, 390 insertions(+), 5 deletions(-)

diff --git a/src/components/SoloScoreboard.ts b/src/components/SoloScoreboard.ts
new file mode 100644
index 0000000..d7b7d9e
--- /dev/null
+++ b/src/components/SoloScoreboard.ts
@@ -0,0 +1,134 @@
+/**
+ * SoloScoreboard — scoreboard for single-player mode.
+ *
+ * Shows the player's name (editable), their running total score, and the
+ * round history (collapsed to the last 2 rounds by default).
+ */
+
+import type { SoloRound } from '../types.js';
+
+interface SoloScoreboardProps {
+    playerName: string;
+    totalScore: number;
+    rounds: SoloRound[];
+    onNameChange: (newName: string) => void;
+}
+
+const COLLAPSED_COUNT = 2;
+
+export function mountSoloScoreboard(
+    container: HTMLElement,
+    props: SoloScoreboardProps,
+): () => void {
+    const { rounds, onNameChange } = props;
+    let { playerName, totalScore } = props;
+
+    const root = document.createElement('div');
+    root.className = 'scoreboard';
+
+    const completedRounds = rounds.filter((r) => r.guesses.length > 0);
+    let expanded = false;
+
+    function render(): void {
+        root.innerHTML = '';
+
+        // --- Player row ---
+        const playersEl = document.createElement('div');
+        playersEl.className = 'scoreboard__players';
+
+        const card = document.createElement('div');
+        card.className = 'scoreboard__player';
+
+        // Editable name
+        const nameRow = document.createElement('div');
+        nameRow.className = 'scoreboard__name-row';
+
+        const nameEl = document.createElement('span');
+        nameEl.className = 'scoreboard__name';
+        nameEl.textContent = playerName || 'Player';
+        nameRow.appendChild(nameEl);
+
+        const editBtn = document.createElement('button');
+        editBtn.className = 'scoreboard__edit-btn';
+        editBtn.title = 'Change name';
+        editBtn.textContent = '✎';
+        editBtn.addEventListener('click', () => {
+            const input = document.createElement('input');
+            input.type = 'text';
+            input.className = 'scoreboard__name-input';
+            input.value = playerName;
+            input.maxLength = 20;
+            nameRow.innerHTML = '';
+            nameRow.appendChild(input);
+            input.focus();
+            input.select();
+
+            function commit() {
+                const val = input.value.trim();
+                if (val && val !== playerName) {
+                    playerName = val;
+                    onNameChange(val);
+                } else {
+                    render();
+                }
+            }
+            input.addEventListener('blur', commit);
+            input.addEventListener('keydown', (e) => {
+                if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
+                if (e.key === 'Escape') { input.removeEventListener('blur', commit); render(); }
+            });
+        });
+        nameRow.appendChild(editBtn);
+        card.appendChild(nameRow);
+
+        const scoreEl = document.createElement('span');
+        scoreEl.className = 'scoreboard__score';
+        scoreEl.textContent = String(totalScore);
+        card.appendChild(scoreEl);
+
+        playersEl.appendChild(card);
+        root.appendChild(playersEl);
+
+        // --- History ---
+        if (completedRounds.length === 0) return;
+
+        const visible = expanded
+            ? completedRounds
+            : completedRounds.slice(-COLLAPSED_COUNT);
+
+        const historyEl = document.createElement('div');
+        historyEl.className = 'scoreboard__history-wrap';
+
+        if (completedRounds.length > COLLAPSED_COUNT) {
+            const toggle = document.createElement('button');
+            toggle.className = 'scoreboard__toggle';
+            toggle.textContent = expanded
+                ? '▲ Hide older rounds'
+                : `▼ Show all ${completedRounds.length} rounds`;
+            toggle.addEventListener('click', () => { expanded = !expanded; render(); });
+            historyEl.appendChild(toggle);
+        }
+
+        const ol = document.createElement('ol');
+        ol.className = 'scoreboard__history';
+        ol.start = expanded ? 1 : completedRounds.length - visible.length + 1;
+        visible.forEach((r) => {
+            const result = r.solved
+                ? `solved "${r.word}" in ${r.guesses.length} ${r.guesses.length === 1 ? 'guess' : 'guesses'} (+${r.score})`
+                : `failed to solve "${r.word}"`;
+            const li = document.createElement('li');
+            li.className = 'scoreboard__round';
+            li.textContent = result;
+            ol.appendChild(li);
+        });
+        historyEl.appendChild(ol);
+        root.appendChild(historyEl);
+    }
+
+    render();
+    container.appendChild(root);
+
+    return function cleanup() {
+        container.removeChild(root);
+    };
+}
diff --git a/src/components/WordleBoard.ts b/src/components/WordleBoard.ts
index 5708506..604c412 100644
--- a/src/components/WordleBoard.ts
+++ b/src/components/WordleBoard.ts
@@ -88,10 +88,16 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps
   const message = document.createElement('p');
   message.className = 'wordle-board__message';
 
+  const giveUpBtn = document.createElement('button');
+  giveUpBtn.type = 'button';
+  giveUpBtn.className = 'wordle-board__give-up';
+  giveUpBtn.textContent = 'Give up';
+
   const keyboardEl = document.createElement('div');
 
   root.appendChild(grid);
   root.appendChild(message);
+  root.appendChild(giveUpBtn);
   root.appendChild(keyboardEl);
   container.appendChild(root);
 
@@ -102,6 +108,15 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps
   const letterStates: Record<string, LetterState> = {};
   const guesses: string[] = [];
 
+  giveUpBtn.addEventListener('click', () => {
+    if (done) return;
+    if (!confirm('Give up this round? The word will be revealed and you\'ll score 0.')) return;
+    done = true;
+    message.textContent = `The word was ${secret}`;
+    giveUpBtn.hidden = true;
+    onRoundEnd({ solved: false, guesses, score: 0 });
+  });
+
   // Mount keyboard — it sends keys back via onKey
   const cleanupKeyboard = mountKeyboard(keyboardEl, {
     onKey: handleKey,
@@ -164,10 +179,12 @@ export function mountWordleBoard(container: HTMLElement, props: WordleBoardProps
     const solved = states.every((s) => s === 'correct');
     if (solved) {
       done = true;
+      giveUpBtn.hidden = true;
       message.textContent = PRAISE[Math.min(guesses.length - 1, PRAISE.length - 1)];
       scheduleRoundEnd(true);
     } else if (currentRow >= MAX_GUESSES) {
       done = true;
+      giveUpBtn.hidden = true;
       message.textContent = `The word was ${secret}`;
       scheduleRoundEnd(false);
     }
diff --git a/src/main.ts b/src/main.ts
index 8f2fc35..7648150 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -16,9 +16,11 @@ import { buildLink } from './utils/shareLink.js';
 import { mountWordPicker } from './components/WordPicker.js';
 import { mountWordleBoard } from './components/WordleBoard.js';
 import { mountScoreboard } from './components/Scoreboard.js';
+import { mountSoloScoreboard } from './components/SoloScoreboard.js';
 import { mountShareDialog } from './components/ShareDialog.js';
 import { notifyMyTurn, clearNotification } from './utils/notifications.js';
-import type { GameState, PlayerId, Round } from './types.js';
+import { getRandomWord } from './utils/wordList.js';
+import type { GameState, PlayerId, Round, SoloGameState, SoloRound } from './types.js';
 
 // ---------------------------------------------------------------------------
 // Waiting status helper
@@ -243,6 +245,107 @@ function render(
     };
 }
 
+// ---------------------------------------------------------------------------
+// Solo mode — purely local, no Firestore
+// ---------------------------------------------------------------------------
+
+const SOLO_KEY = 'word-waltz:solo';
+
+function loadSoloGame(): SoloGameState | null {
+    try {
+        const raw = localStorage.getItem(SOLO_KEY);
+        return raw ? (JSON.parse(raw) as SoloGameState) : null;
+    } catch {
+        return null;
+    }
+}
+
+function saveSoloGame(state: SoloGameState): void {
+    localStorage.setItem(SOLO_KEY, JSON.stringify(state));
+}
+
+function deleteSoloGame(): void {
+    localStorage.removeItem(SOLO_KEY);
+}
+
+function showSoloGame(app: HTMLElement): void {
+    unsubscribeGame?.();
+    unsubscribeGame = null;
+    cleanupView?.();
+    cleanupView = null;
+    app.innerHTML = '';
+
+    let state = loadSoloGame();
+    if (!state) {
+        const name = getSavedName();
+        const firstWord = getRandomWord();
+        const firstRound: SoloRound = { word: firstWord, guesses: [], solved: false, score: 0 };
+        state = {
+            playerName: name || 'You',
+            totalScore: 0,
+            rounds: [firstRound],
+            currentRound: 0,
+            createdAt: Date.now(),
+            updatedAt: Date.now(),
+        };
+        saveSoloGame(state);
+    }
+
+    function renderSolo(current: SoloGameState): void {
+        cleanupView?.();
+        cleanupView = null;
+        app.innerHTML = '';
+
+        const scoreboardEl = document.createElement('div');
+        app.appendChild(scoreboardEl);
+        const cleanupScoreboard = mountSoloScoreboard(scoreboardEl, {
+            playerName: current.playerName,
+            totalScore: current.totalScore,
+            rounds: current.rounds,
+            onNameChange: (newName: string) => {
+                saveName(newName);
+                const updated = { ...current, playerName: newName, updatedAt: Date.now() };
+                saveSoloGame(updated);
+                renderSolo(updated);
+            },
+        });
+
+        const mainEl = document.createElement('div');
+        mainEl.className = 'main-view';
+        app.appendChild(mainEl);
+
+        const round = current.rounds[current.currentRound];
+
+        const cleanupBoard = mountWordleBoard(mainEl, {
+            secretWord: round.word,
+            onRoundEnd: (result) => {
+                const updatedRounds: SoloRound[] = current.rounds.map((r, i) =>
+                    i === current.currentRound ? { ...r, ...result } : r,
+                );
+                const newTotalScore = current.totalScore + result.score;
+                const nextWord = getRandomWord();
+                const nextRound: SoloRound = { word: nextWord, guesses: [], solved: false, score: 0 };
+                const updated: SoloGameState = {
+                    ...current,
+                    totalScore: newTotalScore,
+                    rounds: [...updatedRounds, nextRound],
+                    currentRound: updatedRounds.length,
+                    updatedAt: Date.now(),
+                };
+                saveSoloGame(updated);
+                renderSolo(updated);
+            },
+        });
+
+        cleanupView = () => {
+            cleanupScoreboard();
+            cleanupBoard();
+        };
+    }
+
+    renderSolo(state);
+}
+
 // ---------------------------------------------------------------------------
 // Home screen — game list + new game form
 // ---------------------------------------------------------------------------
@@ -254,9 +357,12 @@ function showHomeScreen(app: HTMLElement): void {
     root.className = 'home-screen';
     app.appendChild(root);
 
-    // --- Existing game list ---
+    // --- Game list (multiplayer + solo merged) ---
     const games = getStoredGames();
-    if (games.length > 0) {
+    const existingSolo = loadSoloGame();
+    const hasAnything = games.length > 0 || existingSolo !== null;
+
+    if (hasAnything) {
         const listSection = document.createElement('div');
         listSection.className = 'game-list';
 
@@ -274,6 +380,7 @@ function showHomeScreen(app: HTMLElement): void {
             if (!confirm('Delete all games? This cannot be undone.')) return;
             const all = getStoredGames();
             clearStoredGames();
+            deleteSoloGame();
             for (const g of all) {
                 deleteGame(g.gameId).catch((err: unknown) =>
                     console.warn('Firestore delete failed for', g.gameId, err));
@@ -286,6 +393,49 @@ function showHomeScreen(app: HTMLElement): void {
         const ul = document.createElement('ul');
         ul.className = 'game-list__items';
 
+        // --- Solo card (always first if it exists) ---
+        if (existingSolo) {
+            const completedCount = existingSolo.rounds.filter(r => r.guesses.length > 0).length;
+            const li = document.createElement('li');
+            li.className = 'game-card game-card--solo';
+
+            const body = document.createElement('div');
+            body.className = 'game-card__body';
+            body.setAttribute('role', 'button');
+            body.tabIndex = 0;
+            body.addEventListener('click', () => showSoloGame(app));
+            body.addEventListener('keydown', (e) => {
+                if (e.key === 'Enter' || e.key === ' ') showSoloGame(app);
+            });
+
+            const labelEl = document.createElement('span');
+            labelEl.className = 'game-card__opponent';
+            labelEl.textContent = 'Solo';
+            body.appendChild(labelEl);
+
+            const statusEl = document.createElement('span');
+            statusEl.className = 'game-card__status game-card__status--my-turn';
+            statusEl.textContent = completedCount === 0
+                ? 'New game — your turn'
+                : `Score: ${existingSolo.totalScore} · ${completedCount} round${completedCount === 1 ? '' : 's'}`;
+            body.appendChild(statusEl);
+
+            const delBtn = document.createElement('button');
+            delBtn.className = 'game-card__delete';
+            delBtn.type = 'button';
+            delBtn.setAttribute('aria-label', 'Delete solo game');
+            delBtn.textContent = '×';
+            delBtn.addEventListener('click', () => {
+                deleteSoloGame();
+                li.remove();
+                if (getStoredGames().length === 0) showHomeScreen(app);
+            });
+
+            li.appendChild(body);
+            li.appendChild(delBtn);
+            ul.appendChild(li);
+        }
+
         for (const game of games) {
             const li = document.createElement('li');
             li.className = 'game-card';
@@ -319,7 +469,7 @@ function showHomeScreen(app: HTMLElement): void {
                 deleteGame(game.gameId).catch((err: unknown) =>
                     console.warn('Firestore delete failed for', game.gameId, err));
                 li.remove();
-                if (getStoredGames().length === 0) showHomeScreen(app);
+                if (getStoredGames().length === 0 && !loadSoloGame()) showHomeScreen(app);
             });
 
             li.appendChild(body);
@@ -340,7 +490,6 @@ function showHomeScreen(app: HTMLElement): void {
     newGameSection.appendChild(desc);
 
     const form = document.createElement('form');
-
     const label = document.createElement('label');
     label.appendChild(document.createTextNode('Your name'));
     const nameInput = document.createElement('input');
@@ -357,6 +506,19 @@ function showHomeScreen(app: HTMLElement): void {
     submitBtn.textContent = 'Create game';
     form.appendChild(submitBtn);
     newGameSection.appendChild(form);
+
+    const orSolo = document.createElement('p');
+    orSolo.className = 'new-game__or';
+    orSolo.textContent = 'or';
+    newGameSection.appendChild(orSolo);
+
+    const soloBtn = document.createElement('button');
+    soloBtn.type = 'button';
+    soloBtn.className = 'new-game__solo-btn';
+    soloBtn.textContent = 'Play Solo';
+    soloBtn.addEventListener('click', () => showSoloGame(app));
+    newGameSection.appendChild(soloBtn);
+
     root.appendChild(newGameSection);
 
     form.addEventListener('submit', async (e) => {
diff --git a/src/styles/main.css b/src/styles/main.css
index 346e0a7..0da06f2 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -346,6 +346,21 @@ header {
     text-align: center;
 }
 
+.wordle-board__give-up {
+    background: none;
+    border: none;
+    padding: 0;
+    font-size: 0.8rem;
+    color: var(--color-muted);
+    cursor: pointer;
+    text-decoration: underline;
+    text-underline-offset: 2px;
+}
+
+.wordle-board__give-up:hover {
+    color: var(--color-error);
+}
+
 /* --- Keyboard --------------------------------------------- */
 /* 10 keys + 9 gaps per row; #app has 12px padding on each side (24px total).
    key-w = (100vw - 24px - 9 × 5px) / 10 = 10vw - 6.9px, capped at 43px.
@@ -711,4 +726,33 @@ body:has(.home-screen) #app {
 
 .site-footer a:hover {
     text-decoration: underline;
+}
+
+/* --- Solo game card accent -------------------------------- */
+.game-card--solo {
+    border-left: 3px solid var(--color-present);
+}
+
+/* --- Solo button in new-game form ------------------------- */
+.new-game__or {
+    font-size: 0.8rem;
+    color: var(--color-muted);
+    margin: 0;
+}
+
+.new-game__solo-btn {
+    padding: 8px 20px;
+    background: none;
+    color: var(--color-present);
+    border: 2px solid var(--color-present);
+    border-radius: var(--radius);
+    font-size: 0.95rem;
+    font-weight: 600;
+    cursor: pointer;
+    width: 100%;
+}
+
+.new-game__solo-btn:hover {
+    background: var(--color-present);
+    color: #fff;
 }
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
index 37d3ff9..3328ab5 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -30,6 +30,27 @@ export interface GameState {
     updatedAt: number;
 }
 
+// ---------------------------------------------------------------------------
+// Solo mode — stored locally in localStorage, no Firestore involved.
+// ---------------------------------------------------------------------------
+
+export interface SoloRound {
+    word: string;
+    guesses: string[];
+    solved: boolean;
+    score: number;
+}
+
+export interface SoloGameState {
+    playerName: string;
+    totalScore: number;
+    rounds: SoloRound[];
+    /** Index of the round currently being played (last element of rounds[]). */
+    currentRound: number;
+    createdAt: number;
+    updatedAt: number;
+}
+
 /** The shape stored in Firestore (encrypted envelope + one plaintext field). */
 export interface EncryptedEnvelope {
     iv: string;
diff --git a/src/utils/wordList.ts b/src/utils/wordList.ts
index 914c730..b720deb 100644
--- a/src/utils/wordList.ts
+++ b/src/utils/wordList.ts
@@ -8512,3 +8512,10 @@ const WORDS = new Set<string>([
 export function isValidWord(word: string): boolean {
   return word.length === 5 && WORDS.has(word.toLowerCase());
 }
+
+const WORDS_ARRAY: string[] = Array.from(WORDS);
+
+/** Returns a random word from the word list, upper-cased. */
+export function getRandomWord(): string {
+  return WORDS_ARRAY[Math.floor(Math.random() * WORDS_ARRAY.length)].toUpperCase();
+}