From 32135ab9fd40550d74518e42038353aa9c9bcdb1 Mon Sep 17 00:00:00 2001 From: lautyscanio Date: Tue, 5 May 2026 09:19:54 -0300 Subject: [PATCH 1/5] Add new brain game: Word Ladder - Landing page with Easy / Medium / Hard difficulty selector - Full gameplay: transform start word into target one letter at a time - BFS-powered hint system showing the next optimal word - Lives system: Easy=5, Medium=3, Hard=2 (lost on invalid words) - Step counter + progress bar per puzzle - Optimal path revealed on game over - Fully responsive from 320px to desktop - Word dictionaries for 3, 4 and 5-letter words - Cognitive benefits & scientific backing section documented - Registered in /game card grid Closes #117 --- public/WordLadder.svg | 20 + src/app/game/page.tsx | 10 +- src/app/game/word-ladder/page.module.css | 348 ++++++++++ src/app/game/word-ladder/page.tsx | 213 ++++++ .../game/word-ladder/play/WordLadderGame.tsx | 432 +++++++++++++ src/app/game/word-ladder/play/page.module.css | 606 ++++++++++++++++++ src/app/game/word-ladder/play/page.tsx | 12 + src/app/game/word-ladder/wordList.ts | 403 ++++++++++++ 8 files changed, 2043 insertions(+), 1 deletion(-) create mode 100644 public/WordLadder.svg create mode 100644 src/app/game/word-ladder/page.module.css create mode 100644 src/app/game/word-ladder/page.tsx create mode 100644 src/app/game/word-ladder/play/WordLadderGame.tsx create mode 100644 src/app/game/word-ladder/play/page.module.css create mode 100644 src/app/game/word-ladder/play/page.tsx create mode 100644 src/app/game/word-ladder/wordList.ts diff --git a/public/WordLadder.svg b/public/WordLadder.svg new file mode 100644 index 0000000..e951461 --- /dev/null +++ b/public/WordLadder.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + A + Z + W→L + diff --git a/src/app/game/page.tsx b/src/app/game/page.tsx index 93328fd..5d168e2 100644 --- a/src/app/game/page.tsx +++ b/src/app/game/page.tsx @@ -7,6 +7,7 @@ import SchulteImage from '/public/SchulteImage.webp' import MemoryFlip from '/public/MemoryFlip.png' import BulletMath from '/public/BulletMath.png' import SimonSaysImage from '/public/SimonSays.png' +import WordLadderImage from '/public/WordLadder.svg' const SelectGame = () => { return ( @@ -60,7 +61,14 @@ const SelectGame = () => { link={'simon-says'} backgroundColor={'#9333EA'} textColor={'white'} - /> + /> + ) diff --git a/src/app/game/word-ladder/page.module.css b/src/app/game/word-ladder/page.module.css new file mode 100644 index 0000000..b139d66 --- /dev/null +++ b/src/app/game/word-ladder/page.module.css @@ -0,0 +1,348 @@ +/* ============================================================ + Word Ladder — Landing Page Styles + Theme colour: #F97316 (orange) + ============================================================ */ + +.container { + max-width: 900px; + margin: 0 auto; + padding: 24px 20px 60px; + color: #1e293b; + font-family: Inter, Helvetica, sans-serif; +} + +/* ── Hero ─────────────────────────────────────────────────── */ +.hero { + text-align: center; + margin-bottom: 48px; +} + +.badge { + display: inline-block; + background: #fff7ed; + color: #c2410c; + border: 1px solid #fed7aa; + border-radius: 999px; + padding: 4px 14px; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + margin-bottom: 16px; +} + +.title { + font-size: clamp(2.2rem, 6vw, 3.6rem); + font-weight: 800; + background: linear-gradient(135deg, #f97316, #ea580c); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin-bottom: 12px; + line-height: 1.1; +} + +.subtitle { + font-size: clamp(1rem, 2.5vw, 1.25rem); + color: #64748b; + max-width: 520px; + margin: 0 auto; + line-height: 1.6; +} + +/* ── Sections ─────────────────────────────────────────────── */ +.section { + background: #ffffff; + border: 1.5px solid #e2e8f0; + border-radius: 20px; + padding: 32px 28px; + margin-bottom: 28px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05); +} + +.sectionTitle { + font-size: 1.35rem; + font-weight: 700; + color: #1e293b; + margin-bottom: 24px; + text-align: center; +} + +/* ── Difficulty ───────────────────────────────────────────── */ +.difficultyGrid { + display: flex; + gap: 16px; + justify-content: center; + flex-wrap: wrap; + margin-bottom: 28px; +} + +.diffBtn { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 18px 22px; + min-width: 150px; + border: 2px solid #e2e8f0; + border-radius: 16px; + background: #f8fafc; + cursor: pointer; + transition: all 0.25s ease; + font-family: inherit; +} + +.diffBtn:hover { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.1); +} + +.diffBtnActive { + transform: translateY(-3px); + box-shadow: 0 6px 20px rgba(249, 115, 22, 0.2); +} + +.diffLabel { + font-size: 1.1rem; + font-weight: 700; + color: #334155; +} + +.diffWordLen { + font-size: 0.85rem; + font-weight: 600; + color: #64748b; +} + +.diffSteps { + font-size: 0.78rem; + color: #94a3b8; +} + +.diffDesc { + font-size: 0.75rem; + color: #94a3b8; + text-align: center; + margin-top: 4px; +} + +.startBtn { + display: block; + margin: 0 auto; + padding: 16px 40px; + font-size: 1.1rem; + font-weight: 700; + color: white; + border: none; + border-radius: 14px; + cursor: pointer; + transition: all 0.25s ease; + box-shadow: 0 4px 14px rgba(249, 115, 22, 0.35); + font-family: inherit; +} + +.startBtn:hover { + filter: brightness(1.1); + transform: translateY(-2px); + box-shadow: 0 8px 22px rgba(249, 115, 22, 0.4); +} + +.startBtn:active { + transform: translateY(0); +} + +/* ── How-to-play ──────────────────────────────────────────── */ +.rulesList { + display: flex; + flex-direction: column; + gap: 14px; + margin-bottom: 28px; +} + +.rule { + display: flex; + align-items: flex-start; + gap: 14px; +} + +.ruleNum { + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(135deg, #f97316, #ea580c); + color: white; + font-weight: 700; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; +} + +.rule p { + line-height: 1.6; + color: #475569; + padding-top: 4px; +} + +/* ── Example ──────────────────────────────────────────────── */ +.exampleBox { + background: #fff7ed; + border: 1.5px solid #fed7aa; + border-radius: 14px; + padding: 20px; + text-align: center; +} + +.exampleLabel { + font-size: 0.85rem; + font-weight: 600; + color: #9a3412; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 12px; +} + +.exampleChain { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + flex-wrap: wrap; +} + +.exampleItem { + display: flex; + align-items: center; + gap: 6px; +} + +.exampleWord { + background: white; + border: 2px solid #f97316; + border-radius: 8px; + padding: 6px 12px; + font-family: monospace; + font-size: 1.1rem; + font-weight: 700; + color: #ea580c; + letter-spacing: 0.1em; +} + +.arrow { + font-size: 1.1rem; + color: #f97316; + font-weight: 700; +} + +/* ── Benefits ─────────────────────────────────────────────── */ +.benefitsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 18px; +} + +.benefit { + background: #f8fafc; + border: 1.5px solid #e2e8f0; + border-radius: 14px; + padding: 20px 16px; + text-align: center; +} + +.benefitIcon { + font-size: 2rem; + margin-bottom: 10px; +} + +.benefit h3 { + font-size: 0.95rem; + font-weight: 700; + color: #1e293b; + margin-bottom: 8px; +} + +.benefit p { + font-size: 0.82rem; + color: #64748b; + line-height: 1.55; +} + +/* ── Science ──────────────────────────────────────────────── */ +.scienceSection { + background: #f0fdf4; + border-color: #bbf7d0; +} + +.scienceList { + list-style: none; + display: flex; + flex-direction: column; + gap: 14px; +} + +.scienceList li { + padding: 14px 18px; + background: white; + border-radius: 10px; + border-left: 4px solid #22c55e; + font-size: 0.88rem; + color: #374151; + line-height: 1.6; +} + +.scienceList li em { + color: #16a34a; +} + +/* ── Bottom CTA ───────────────────────────────────────────── */ +.ctaBottom { + text-align: center; + margin-top: 12px; +} + +/* ── Responsive ───────────────────────────────────────────── */ +@media (max-width: 600px) { + .section { + padding: 22px 16px; + } + + .difficultyGrid { + flex-direction: column; + align-items: stretch; + } + + .diffBtn { + flex-direction: row; + justify-content: flex-start; + min-width: unset; + gap: 12px; + } + + .diffLabel { + min-width: 64px; + } + + .diffDesc { + display: none; + } + + .exampleChain { + gap: 4px; + } + + .exampleWord { + font-size: 0.9rem; + padding: 4px 8px; + } + + .benefitsGrid { + grid-template-columns: 1fr 1fr; + } +} + +@media (max-width: 360px) { + .benefitsGrid { + grid-template-columns: 1fr; + } +} diff --git a/src/app/game/word-ladder/page.tsx b/src/app/game/word-ladder/page.tsx new file mode 100644 index 0000000..7bf7efd --- /dev/null +++ b/src/app/game/word-ladder/page.tsx @@ -0,0 +1,213 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import styles from './page.module.css' +import type { Difficulty } from './wordList' + +const DIFFICULTY_INFO: Record< + Difficulty, + { label: string; description: string; wordLen: string; steps: string; color: string } +> = { + easy: { + label: 'Easy', + description: '3-letter words. Great for beginners.', + wordLen: '3 letters', + steps: 'Up to 7 steps', + color: '#22C55E', + }, + medium: { + label: 'Medium', + description: '4-letter words. A solid challenge.', + wordLen: '4 letters', + steps: 'Up to 9 steps', + color: '#F97316', + }, + hard: { + label: 'Hard', + description: '5-letter words. For expert wordsmiths.', + wordLen: '5 letters', + steps: 'Up to 8 steps', + color: '#EF4444', + }, +} + +export default function WordLadderLanding() { + const [difficulty, setDifficulty] = useState('medium') + const router = useRouter() + + const handleStart = () => { + router.push(`/game/word-ladder/play?difficulty=${difficulty}`) + } + + return ( +
+ {/* ── Hero ── */} +
+
🧠 Brain Training
+

Word Ladder

+

+ Transform one word into another — one letter at a time. +

+
+ + {/* ── Difficulty Picker ── */} +
+

Choose Difficulty

+ +
+ {(Object.keys(DIFFICULTY_INFO) as Difficulty[]).map((d) => { + const info = DIFFICULTY_INFO[d] + return ( + + ) + })} +
+ + +
+ + {/* ── How to Play ── */} +
+

📖 How to Play

+
+
+ 1 +

+ You are given a start word and a{' '} + target word of the same length. +

+
+
+ 2 +

+ Each step you must type a valid English word that differs from the + previous word by exactly one letter. +

+
+
+ 3 +

+ Reach the target word before you run out of steps to win! +

+
+
+ 4 +

+ Stuck? Use the 💡 Hint button to reveal the next + optimal word (use sparingly). +

+
+
+ +
+

Example: COLD → WARM

+
+ {['COLD', 'CORD', 'WORD', 'WARD', 'WARM'].map((w, i, arr) => ( + + {w} + {i < arr.length - 1 && } + + ))} +
+
+
+ + {/* ── Cognitive Benefits ── */} +
+

🧠 Cognitive Benefits

+
+
+
💾
+

Working Memory

+

+ Holding multiple possible transformations in mind at once + strengthens working memory and trains the prefrontal cortex. +

+
+
+
🔍
+

Pattern Recognition

+

+ Spotting letter patterns and word families sharpens visual memory + and orthographic processing in the left hemisphere. +

+
+
+
🎯
+

Strategic Thinking

+

+ Planning several moves ahead builds executive function and + forward-planning ability tied to the prefrontal cortex. +

+
+
+
📚
+

Vocabulary Recall

+

+ Retrieving words under mild pressure reinforces long-term memory + pathways and improves verbal fluency. +

+
+
+
+ + {/* ── Scientific Backing ── */} +
+

📄 Scientific Backing

+
    +
  • + Verbal fluency & memory: Word puzzles improve + working memory and cognitive flexibility.{' '} + + Journal of Neuropsychiatry & Clinical Neurosciences, 2014 + + . +
  • +
  • + Aphasia rehabilitation: Word-ladder style tasks are + used clinically to strengthen word-retrieval pathways damaged by + stroke. (Stroke Rehabilitation Research, 2018) +
  • +
  • + Cognitive Reserve Theory: Regular engagement with + word games builds mental resilience and is associated with reduced + risk of cognitive decline in aging.{' '} + Stern, Y. — Cognitive reserve in ageing and Alzheimer's disease, 2012 + . +
  • +
+
+ + {/* ── Final CTA ── */} +
+ +
+
+ ) +} diff --git a/src/app/game/word-ladder/play/WordLadderGame.tsx b/src/app/game/word-ladder/play/WordLadderGame.tsx new file mode 100644 index 0000000..3b10240 --- /dev/null +++ b/src/app/game/word-ladder/play/WordLadderGame.tsx @@ -0,0 +1,432 @@ +'use client' + +import React, { useState, useEffect, useRef, useCallback } from 'react' +import { useRouter, useSearchParams } from 'next/navigation' +import styles from './page.module.css' +import { + getRandomPuzzle, + isValidWord, + getHint, + diffCount, + type Difficulty, + type PuzzlePair, +} from '../wordList' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type GameStatus = 'playing' | 'won' | 'lost' + +type ErrorKind = + | 'not-a-word' + | 'wrong-length' + | 'multiple-changes' + | 'no-change' + | 'already-used' + | null + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Returns an array describing which positions changed vs the previous word */ +function getChangedPositions(prev: string, current: string): boolean[] { + return current.split('').map((ch, i) => ch !== prev[i]) +} + +const ERROR_MESSAGES: Record, string> = { + 'not-a-word': '❌ Not a valid word.', + 'wrong-length': '❌ Word must be the same length as the start word.', + 'multiple-changes': '❌ You can only change ONE letter at a time.', + 'no-change': '❌ The word must be different from the current one.', + 'already-used': '⚠️ You already used that word.', +} + +// --------------------------------------------------------------------------- +// Lives per difficulty +// --------------------------------------------------------------------------- + +const MAX_LIVES: Record = { + easy: 5, + medium: 3, + hard: 2, +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export default function WordLadderGame() { + const router = useRouter() + const searchParams = useSearchParams() + const difficulty = (searchParams.get('difficulty') ?? 'medium') as Difficulty + + const [puzzle, setPuzzle] = useState(null) + const [chain, setChain] = useState([]) + const [input, setInput] = useState('') + const [status, setStatus] = useState('playing') + const [error, setError] = useState(null) + const [hint, setHint] = useState(null) + const [hintsUsed, setHintsUsed] = useState(0) + const [lives, setLives] = useState(MAX_LIVES[difficulty]) + const [shake, setShake] = useState(false) + const inputRef = useRef(null) + + const maxLives = MAX_LIVES[difficulty] + + // Initialise a new puzzle + const initPuzzle = useCallback(() => { + const p = getRandomPuzzle(difficulty) + setPuzzle(p) + setChain([p.start]) + setInput('') + setStatus('playing') + setError(null) + setHint(null) + setHintsUsed(0) + setLives(MAX_LIVES[difficulty]) + }, [difficulty]) + + useEffect(() => { + initPuzzle() + }, [initPuzzle]) + + useEffect(() => { + if (status === 'playing') inputRef.current?.focus() + }, [status, chain]) + + // ── Derived ───────────────────────────────────────────────────────────── + const stepsUsed = chain.length - 1 + const stepsLeft = puzzle ? puzzle.maxSteps - stepsUsed : 0 + const currentWord = chain[chain.length - 1] ?? '' + + // ── Submit a word ──────────────────────────────────────────────────────── + const handleSubmit = () => { + if (!puzzle || status !== 'playing') return + const word = input.trim().toUpperCase() + if (word === '') return + + setHint(null) + + // ── Validations that do NOT cost a life ── + if (word.length !== puzzle.start.length) { + triggerError('wrong-length') + return + } + if (word === currentWord) { + triggerError('no-change') + return + } + if (diffCount(word, currentWord) !== 1) { + triggerError('multiple-changes') + return + } + if (chain.includes(word)) { + triggerError('already-used') + return + } + + // ── Validation that COSTS a life: not in dictionary ── + if (!isValidWord(word)) { + triggerError('not-a-word') + const newLives = lives - 1 + setLives(newLives) + if (newLives <= 0) { + setTimeout(() => setStatus('lost'), 400) + } + return + } + + const newChain = [...chain, word] + setChain(newChain) + setInput('') + setError(null) + + if (word === puzzle.target) { + setStatus('won') + return + } + + if (newChain.length - 1 >= puzzle.maxSteps) { + setStatus('lost') + } + } + + const triggerError = (kind: ErrorKind) => { + setError(kind) + setShake(true) + setTimeout(() => setShake(false), 500) + } + + const handleHint = () => { + if (!puzzle || status !== 'playing') return + const next = getHint(currentWord, puzzle.target) + if (next) { + setHint(next) + setHintsUsed((h: number) => h + 1) + } else { + setHint('no-path') + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSubmit() + } + + // ── Render ─────────────────────────────────────────────────────────────── + if (!puzzle) { + return
Loading puzzle…
+ } + + const progressPct = Math.min((stepsUsed / puzzle.maxSteps) * 100, 100) + const progressColor = + stepsLeft > puzzle.maxSteps * 0.5 + ? '#22c55e' + : stepsLeft > puzzle.maxSteps * 0.25 + ? '#f97316' + : '#ef4444' + + return ( +
+ {/* ── Header ── */} +
+ +

Word Ladder

+ + {difficulty.charAt(0).toUpperCase() + difficulty.slice(1)} + +
+ + {/* ── Stats bar ── */} +
+
+ Steps used + {stepsUsed} +
+
+ Steps left + + {stepsLeft} + +
+
+ Hints used + {hintsUsed} +
+
+ + {/* ── Lives ── */} +
+ {Array.from({ length: maxLives }).map((_, i) => ( + + {i < lives ? '❤️' : '🖤'} + + ))} + + {lives > 0 ? `${lives} life${lives !== 1 ? 'ves' : ''} remaining` : 'No lives left'} + +
+ + {/* ── Progress bar ── */} +
+
+
+ + {/* ── Target banner ── */} +
+ Target +
+ {puzzle.target.split('').map((letter, i) => { + const matched = currentWord[i] === letter + return ( + + {letter} + + ) + })} +
+
+ + {/* ── Word chain ── */} +
+ {chain.map((word, wi) => { + const isFirst = wi === 0 + const isLast = wi === chain.length - 1 + const prevWord = wi > 0 ? chain[wi - 1] : null + const changed = prevWord ? getChangedPositions(prevWord, word) : null + + return ( +
+ {wi === 0 ? '★' : wi} +
+ {word.split('').map((letter, li) => { + const wasChanged = changed ? changed[li] : false + return ( + + {letter} + + ) + })} +
+ {isFirst && Start} +
+ ) + })} +
+ + {/* ── Hint display ── */} + {hint && hint !== 'no-path' && status === 'playing' && ( +
+ 💡 Try: {hint} +
+ )} + {hint === 'no-path' && status === 'playing' && ( +
+ ❌ No path found from here — try a different route! +
+ )} + + {/* ── Input area ── */} + {status === 'playing' && ( +
+ { + setError(null) + setInput(e.target.value.toUpperCase().slice(0, puzzle.start.length)) + }} + onKeyDown={handleKeyDown} + maxLength={puzzle.start.length} + placeholder={'_'.repeat(puzzle.start.length)} + aria-label="Enter your next word" + autoComplete="off" + autoCorrect="off" + autoCapitalize="characters" + spellCheck={false} + /> + + +
+ )} + + {/* ── Error message ── */} + {error && ( +

+ {ERROR_MESSAGES[error]} + {error === 'not-a-word' && ' (-1 ❤️)'} +

+ )} + + {/* ── Win screen ── */} + {status === 'won' && ( +
+
🎉
+

You did it!

+

+ {puzzle.start}{puzzle.target} in{' '} + {stepsUsed} step{stepsUsed !== 1 ? 's' : ''} with{' '} + {lives} life{lives !== 1 ? 'ves' : ''} remaining. + {hintsUsed === 0 && ' No hints — impressive! 🧠'} +

+
+ + +
+
+ )} + + {/* ── Lose screen ── */} + {status === 'lost' && ( +
+
{lives <= 0 ? '💔' : '😔'}
+

+ {lives <= 0 ? 'Out of lives!' : 'Out of steps!'} +

+

+ You couldn't reach {puzzle.target}. +

+ +
+ + +
+
+ )} +
+ ) +} + +// --------------------------------------------------------------------------- +// Sub-component: shows the BFS optimal path on loss +// --------------------------------------------------------------------------- +function OptimalPath({ start, target }: { start: string; target: string }) { + const [path, setPath] = useState(null) + + useEffect(() => { + // Run BFS in a microtask to avoid blocking the render + const timer = setTimeout(() => { + // Dynamically import to keep the initial bundle clean + import('../wordList').then(({ bfs, getWordSet }) => { + const wordSet = getWordSet(start.length) + const found = bfs(start, target, wordSet as Set) + setPath(found) + }) + }, 50) + return () => clearTimeout(timer) + }, [start, target]) + + if (!path) return

Calculating optimal path…

+ + return ( +
+

One possible path ({path.length - 1} steps):

+
+ {path.map((word, i) => ( + + {word} + {i < path.length - 1 && } + + ))} +
+
+ ) +} diff --git a/src/app/game/word-ladder/play/page.module.css b/src/app/game/word-ladder/play/page.module.css new file mode 100644 index 0000000..c2bd4b3 --- /dev/null +++ b/src/app/game/word-ladder/play/page.module.css @@ -0,0 +1,606 @@ +/* ============================================================ + Word Ladder — Play Page Styles + ============================================================ */ + +/* ── Layout ─────────────────────────────────────────────────── */ +.container { + max-width: 680px; + margin: 0 auto; + padding: 20px 16px 60px; + font-family: Inter, Helvetica, sans-serif; + color: #1e293b; + min-height: 100vh; +} + +.loading { + text-align: center; + padding: 80px 20px; + font-size: 1.1rem; + color: #64748b; +} + +/* ── Header ─────────────────────────────────────────────────── */ +.header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.backBtn { + background: #f1f5f9; + border: 1.5px solid #e2e8f0; + color: #475569; + padding: 8px 16px; + border-radius: 10px; + cursor: pointer; + font-size: 0.9rem; + font-weight: 600; + transition: all 0.2s; + font-family: inherit; +} + +.backBtn:hover { + background: #e2e8f0; + transform: translateX(-2px); +} + +.title { + flex: 1; + font-size: 1.6rem; + font-weight: 800; + background: linear-gradient(135deg, #f97316, #ea580c); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0; +} + +.diffBadge { + padding: 4px 14px; + border-radius: 999px; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.diffBadge[data-difficulty='easy'] { + background: #dcfce7; + color: #15803d; +} +.diffBadge[data-difficulty='medium'] { + background: #ffedd5; + color: #c2410c; +} +.diffBadge[data-difficulty='hard'] { + background: #fee2e2; + color: #b91c1c; +} + +/* ── Stats ───────────────────────────────────────────────────── */ +.statsBar { + display: flex; + gap: 12px; + justify-content: center; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.stat { + display: flex; + flex-direction: column; + align-items: center; + background: #f8fafc; + border: 1.5px solid #e2e8f0; + border-radius: 12px; + padding: 10px 20px; + min-width: 90px; +} + +.statLabel { + font-size: 0.72rem; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.statValue { + font-size: 1.5rem; + font-weight: 800; + color: #1e293b; + line-height: 1.2; +} + +/* ── Lives ───────────────────────────────────────────────────── */ +.livesRow { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.heart { + font-size: 1.5rem; + transition: transform 0.2s ease; + line-height: 1; +} + +.heartAlive { + transform: scale(1); + filter: drop-shadow(0 0 4px rgba(239, 68, 68, 0.4)); +} + +.heartDead { + opacity: 0.35; + filter: grayscale(1); +} + +.livesLabel { + font-size: 0.78rem; + font-weight: 600; + color: #94a3b8; + margin-left: 6px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ── Progress bar ────────────────────────────────────────────── */ +.progressTrack { + width: 100%; + height: 6px; + background: #e2e8f0; + border-radius: 999px; + overflow: hidden; + margin-bottom: 20px; +} + +.progressFill { + height: 100%; + border-radius: 999px; + transition: width 0.4s ease, background-color 0.4s ease; +} + +/* ── Target banner ───────────────────────────────────────────── */ +.targetBanner { + text-align: center; + margin-bottom: 20px; +} + +.targetLabel { + display: block; + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #f97316; + margin-bottom: 8px; +} + +/* ── Word tiles ──────────────────────────────────────────────── */ +.wordRow { + display: flex; + gap: 6px; + justify-content: center; +} + +.tile { + width: 48px; + height: 52px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Courier New', monospace; + font-size: 1.3rem; + font-weight: 800; + letter-spacing: 0; + border: 2px solid transparent; + transition: transform 0.15s ease; + user-select: none; +} + +/* Target tiles */ +.tileTarget { + background: #fff7ed; + border-color: #fed7aa; + color: #c2410c; +} + +.tileMatch { + background: #dcfce7; + border-color: #86efac; + color: #15803d; +} + +/* Chain tiles */ +.tileStart { + background: #f0f9ff; + border-color: #bae6fd; + color: #0369a1; +} + +.tileSame { + background: #f8fafc; + border-color: #e2e8f0; + color: #475569; +} + +.tileChanged { + background: #fff7ed; + border-color: #f97316; + color: #ea580c; + transform: scale(1.05); +} + +.tileWon { + background: linear-gradient(135deg, #22c55e, #16a34a) !important; + border-color: #16a34a !important; + color: white !important; + animation: popIn 0.4s ease forwards; +} + +@keyframes popIn { + 0% { transform: scale(0.8); } + 60% { transform: scale(1.12); } + 100% { transform: scale(1); } +} + +/* ── Chain ───────────────────────────────────────────────────── */ +.chainContainer { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 20px; +} + +.chainRow { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px; + border-radius: 12px; + background: transparent; + transition: background 0.2s; +} + +.chainStart { + background: #f0f9ff; +} + +.chainCurrent { + background: #fff7ed; + border: 1.5px dashed #f97316; +} + +.chainWon .tile { + animation: popIn 0.4s ease forwards; +} + +.chainIndex { + font-size: 0.75rem; + font-weight: 700; + color: #94a3b8; + width: 24px; + text-align: center; + flex-shrink: 0; +} + +.startBadge { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + color: #0369a1; + background: #e0f2fe; + padding: 2px 8px; + border-radius: 999px; + letter-spacing: 0.05em; +} + +/* ── Hint ────────────────────────────────────────────────────── */ +.hintBox { + text-align: center; + background: #fefce8; + border: 1.5px solid #fde68a; + border-radius: 12px; + padding: 12px 20px; + font-size: 0.95rem; + color: #92400e; + margin-bottom: 16px; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Input area ──────────────────────────────────────────────── */ +.inputArea { + display: flex; + gap: 8px; + justify-content: center; + align-items: center; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.wordInput { + font-family: 'Courier New', monospace; + font-size: 1.4rem; + font-weight: 800; + letter-spacing: 0.2em; + text-align: center; + text-transform: uppercase; + padding: 12px 16px; + border: 2.5px solid #e2e8f0; + border-radius: 12px; + outline: none; + width: 180px; + transition: border-color 0.2s, box-shadow 0.2s; + background: #f8fafc; + color: #1e293b; +} + +.wordInput:focus { + border-color: #f97316; + box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.15); + background: white; +} + +.submitBtn { + padding: 12px 22px; + background: linear-gradient(135deg, #f97316, #ea580c); + color: white; + border: none; + border-radius: 12px; + font-size: 0.95rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; +} + +.submitBtn:hover { + filter: brightness(1.1); + transform: translateY(-1px); +} + +.submitBtn:active { + transform: translateY(0); +} + +.hintBtn { + padding: 12px 18px; + background: #fefce8; + border: 1.5px solid #fde68a; + color: #92400e; + border-radius: 12px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; +} + +.hintBtn:hover { + background: #fef3c7; + border-color: #fbbf24; +} + +/* ── Error ───────────────────────────────────────────────────── */ +.errorMsg { + text-align: center; + color: #b91c1c; + background: #fee2e2; + border: 1.5px solid #fca5a5; + border-radius: 10px; + padding: 10px 16px; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 12px; + animation: fadeIn 0.2s ease; +} + +/* ── Shake animation ─────────────────────────────────────────── */ +.shake { + animation: shake 0.4s ease; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20% { transform: translateX(-8px); } + 40% { transform: translateX(8px); } + 60% { transform: translateX(-5px); } + 80% { transform: translateX(5px); } +} + +/* ── Result cards ────────────────────────────────────────────── */ +.resultCard { + text-align: center; + border-radius: 20px; + padding: 32px 24px; + margin-top: 20px; + animation: fadeIn 0.5s ease; +} + +.resultCard[data-result='won'] { + background: linear-gradient(135deg, #f0fdf4, #dcfce7); + border: 2px solid #86efac; +} + +.resultCard[data-result='lost'] { + background: #fef2f2; + border: 2px solid #fca5a5; +} + +.resultEmoji { + font-size: 3.5rem; + margin-bottom: 12px; +} + +.resultTitle { + font-size: 1.8rem; + font-weight: 800; + margin-bottom: 10px; + color: #1e293b; +} + +.resultDesc { + font-size: 0.95rem; + color: #475569; + line-height: 1.6; + margin-bottom: 20px; +} + +.resultActions { + display: flex; + gap: 12px; + justify-content: center; + flex-wrap: wrap; +} + +.playAgainBtn { + padding: 14px 28px; + background: linear-gradient(135deg, #f97316, #ea580c); + color: white; + border: none; + border-radius: 12px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; +} + +.playAgainBtn:hover { + filter: brightness(1.1); + transform: translateY(-2px); +} + +.backBtnResult { + padding: 14px 28px; + background: white; + border: 2px solid #e2e8f0; + color: #475569; + border-radius: 12px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; +} + +.backBtnResult:hover { + background: #f8fafc; + border-color: #cbd5e1; +} + +/* ── Optimal path (shown on loss) ────────────────────────────── */ +.optimalPath { + background: white; + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; + border: 1.5px solid #e2e8f0; +} + +.optimalLabel { + font-size: 0.8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #94a3b8; + margin-bottom: 10px; +} + +.optimalChain { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + align-items: center; +} + +.optimalItem { + display: flex; + align-items: center; + gap: 6px; +} + +.optimalWord { + background: #f8fafc; + border: 1.5px solid #e2e8f0; + border-radius: 8px; + padding: 4px 10px; + font-family: monospace; + font-size: 0.95rem; + font-weight: 700; + color: #334155; + letter-spacing: 0.08em; +} + +.optimalArrow { + color: #94a3b8; + font-size: 0.9rem; +} + +/* ── Responsive ──────────────────────────────────────────────── */ +@media (max-width: 480px) { + .tile { + width: 40px; + height: 46px; + font-size: 1.1rem; + border-radius: 8px; + } + + .wordInput { + width: 150px; + font-size: 1.2rem; + letter-spacing: 0.15em; + } + + .submitBtn, + .hintBtn { + padding: 10px 16px; + font-size: 0.88rem; + } + + .statsBar { + gap: 8px; + } + + .stat { + padding: 8px 14px; + min-width: 76px; + } + + .statValue { + font-size: 1.2rem; + } + + .title { + font-size: 1.3rem; + } +} + +@media (max-width: 360px) { + .tile { + width: 34px; + height: 40px; + font-size: 0.95rem; + border-radius: 6px; + } + + .wordRow { + gap: 4px; + } + + .wordInput { + width: 130px; + } +} diff --git a/src/app/game/word-ladder/play/page.tsx b/src/app/game/word-ladder/play/page.tsx new file mode 100644 index 0000000..e45b6a4 --- /dev/null +++ b/src/app/game/word-ladder/play/page.tsx @@ -0,0 +1,12 @@ +'use client' + +import { Suspense } from 'react' +import WordLadderGame from './WordLadderGame' + +export default function WordLadderPlayPage() { + return ( + Loading…
}> + + + ) +} diff --git a/src/app/game/word-ladder/wordList.ts b/src/app/game/word-ladder/wordList.ts new file mode 100644 index 0000000..c474461 --- /dev/null +++ b/src/app/game/word-ladder/wordList.ts @@ -0,0 +1,403 @@ +/** + * Word Ladder Game — Word List & Game Logic + * + * Contains: + * - Curated dictionaries for 3, 4 and 5-letter words + * - Puzzle pairs grouped by difficulty + * - BFS pathfinder for optimal hint generation + * - Helper validation functions + */ + +// --------------------------------------------------------------------------- +// Dictionaries +// --------------------------------------------------------------------------- + +export const WORDS_3: ReadonlySet = new Set([ + 'ace','act','add','age','ago','aid','aim','air','ale','ant','ape','arc','are','ark','arm', + 'art','ash','ask','ate','awe','axe','aye', + 'bad','bag','ban','bar','bat','bay','bed','bee','beg','bet','bid','big','bin','bit','bog', + 'bow','box','boy','bud','bug','bum','bun','bus','but','buy','bye', + 'cab','can','cap','car','cat','cod','cog','cop','cot','cow','cry','cub','cup','cut', + 'dad','dam','dew','did','dig','dim','dip','dog','dot','dry','dug','dun', + 'ear','eat','egg','ego','elf','elk','elm','end','era','eve','ewe', + 'fad','fan','far','fat','fee','few','fig','fin','fit','fly','fog','foe','for','fox','fry','fun', + 'gap','gas','gel','gem','get','god','got','gun','guy','gym','gum', + 'had','ham','has','hat','hay','hen','hew','hid','him','hip','his','hit','hoe','hop','hot', + 'hub','hug','hum','hut', + 'ice','inn','ion','ivy', + 'jab','jam','jar','jaw','jet','job','jot','joy','jug','jut', + 'keg','key','kid','kin','kit', + 'lab','lag','lap','law','lay','led','leg','let','lid','lip','lit','log','lot','low','lug', + 'mad','man','map','mat','may','mob','mop','mud','mug','mum', + 'nag','nap','net','nod','nor','not','now','nun', + 'oak','oar','odd','oil','old','one','ore','our','out','own', + 'pad','pan','paw','pay','peg','pen','pet','pin','pit','pod','pop','pot','pub','pun','pup','put', + 'rag','ram','ran','rap','rat','raw','ray','red','rid','rim','rip','rob','rod','rot','row', + 'rub','rug','rum','run','rut', + 'sad','sap','sat','saw','say','sea','set','sew','shy','sip','sit','sky','sly','sob','sod', + 'son','sow','spa','spy','sub','sue','sum','sun', + 'tab','tan','tap','tar','tea','ten','tin','tip','toe','ton','top','tow','toy','tub','tug', + 'urn','use', + 'van','vat','via','vie','vim','vow', + 'wag','war','was','way','web','wed','wet','who','why','wig','win','wit','won','wow','wry', + 'yam','yew','yet','you', + 'zap','zip', +]) + +export const WORDS_4: ReadonlySet = new Set([ + 'able','ache','acre','aged','aide','also','alto','army','arts','asks','atom','aunt','away', + 'back','bade','bail','bait','bake','bald','bale','ball','band','bane','bang','bank','bare', + 'bark','barn','base','bash','bath','bead','beak','beam','bean','bear','beat','been','beer', + 'beet','bell','belt','bend','best','bile','bill','bind','bird','bite','blow','blue', + 'boar','boat','bold','bolt','bond','bone','book','boom','boot','bore','born','both','bout', + 'bowl','bulk','bull','bump','burn','bush','busy','buzz', + 'cafe','cage','cake','calf','call','calm','came','camp','cane','card','care','cart','case', + 'cash','cast','cave','cent','char','chat','chip','chop','cite','clad','clam','clap', + 'claw','clay','clip','clog','clot','club','clue','coat','code','coil','coin','coke', + 'come','cone','cook','cool','cope','copy','cord','core','cork','corn','cost','curl', + 'cute', + 'damp','dare','dark','dart','dash','data','date','dawn','dead','deaf','deal','dear', + 'debt','deck','deed','deep','deer','dent','deny','desk','dial','dice','diet','dill', + 'dime','dine','dire','dirt','disc','dish','disk','dome','done','doom','door','dose', + 'dote','dove','down','drag','draw','drip','drop','drum','dual','duel','dumb','dump', + 'dune','dunk','dusk','dust', + 'each','earl','earn','ease','east','edge','emit','even','ever','evil','exam', + 'face','fact','fade','fail','fair','fake','fall','fame','fang','farm','fast','fate','fawn', + 'fear','feat','feed','feel','feet','fell','felt','fend','fern','file','fill','film', + 'find','fine','fire','firm','fish','fist','flag','flap','flat','flaw','flea','flew', + 'flex','flip','flog','flop','flow','foam','fold','folk','fond','font','food','fool', + 'foot','ford','fore','fork','form','fort','foul','four','fowl','free','frog','fuel', + 'full','fume','fund','fuse', + 'gale','gall','game','gang','gape','garb','gate','gave','gaze','gear','gent','germ','gild', + 'gill','gist','give','glad','glen','glow','glue','goal','goat','gold','golf', + 'gone','good','gore','gory','gown','grab','grey','grid','grim','grip','grit','grew','gulf', + 'gull','gulp','gust','guts', + 'hack','hail','hair','half','hall','halt','hand','hang','hard','hare','harm','hash','hate', + 'have','hawk','haze','hazy','head','heal','heap','hear','heat','heed','heel','held','helm', + 'help','hemp','herb','herd','hero','hide','high','hill','hilt','hint','hire','hive', + 'hold','hole','home','hood','hook','hoop','hope','horn','hose','hour','howl','hull','hump', + 'hunk','hunt','hurl', + 'iced','icon','idea','idle','inch','into','iris','iron', + 'jack','jade','jail','jest','join','joke','jolt','jump','jury','just', + 'keel','keen','keep','kelp','kept','kick','kill','kind','king','kite','knee','knob','knot','know', + 'lace','lack','laid','lake','lame','lamp','land','lane','lard','lark','lash','last','late', + 'lawn','lead','leaf','leak','lean','leap','leer','lend','lens','lift','like','lily', + 'lime','limp','line','link','lion','list','live','load','loam','loan','lobe','lock','loft', + 'lone','long','loom','loop','lore','lose','loss','lost','loud','lout','love','lure', + 'lurk','lust','lute', + 'made','mail','main','make','male','mall','mane','many','mark','mast','mate','maze', + 'meal','mean','meat','meld','melt','memo','mere','mesh','mild','mile','milk','mill', + 'mime','mind','mine','mint','mire','miss','mist','mode','mold','mole','molt','mood', + 'moon','moor','more','most','moth','move','mule','mull','muse','musk','mute', + 'nail','name','navy','need','nest','news','next','nice','nine','node','none','noon','nope', + 'norm','nose','note','noun','nude','null', + 'once','only','open','oral','oven','over','owed', + 'pace','pack','page','paid','pail','pain','pair','pale','palm','pang','park','part','pass', + 'past','path','pave','peak','pear','peel','peer','pelt','pest','pile','pill','pine','pink', + 'pipe','plan','play','plod','plop','plot','plow','plug','plum','plus','poem','poet', + 'pole','poll','pond','pore','port','pose','post','pour','prey','prim','prod', + 'prop','pull','pump','pure','push', + 'rack','rage','raid','rail','rain','rake','ramp','rank','rant','rash','rate','read','real', + 'reap','reed','reel','rein','rent','rest','rice','rich','ride','rife','rift','ring','riot', + 'rise','risk','road','roam','roar','robe','rock','rode','role','roll','roof','room', + 'rope','rose','ruin','rule','rump','rung','rush', + 'sack','safe','sage','sail','sake','sale','salt','same','sand','sane','sang','sank','sash', + 'save','scan','scar','seal','seam','seat','seed','seek','seem','seep','self','sell','send', + 'sent','shed','shin','ship','shoe','shop','shot','show','shut','sick','sill','silo','silt', + 'sing','sink','site','size','skin','skip','slab','slam','slap','slat','sled','slim', + 'slip','slit','slow','slug','snap','snob','snug','soak','soap','soar', + 'sock','soft','soil','sold','sole','some','song','soon','soot','sort','soul','soup','sour', + 'span','spar','spin','spot','spur','star','stay','stem','step','stir','stop', + 'stub','such','suit','sulk','surf', + 'tack','tail','tale','talk','tall','tame','tank','tape','tare','task','taut','teal','team', + 'tear','teem','tell','tend','tent','term','test','tick','tide','tile','till','tilt','time', + 'tire','toad','told','toll','tomb','tome','tone','took','tool','torn','toss','tour','town', + 'trek','trim','trio','trip','tube','tuna','tune','turf','twin','type', + 'ugly','undo','unit','unto','upon','used', + 'vale','vane','vary','vast','veal','veil','vein','vent','very','vest','veto','vice','view', + 'vine','vise','void','vole','volt','vote', + 'wade','wage','wake','walk','wall','wand','wane','ward','warm','wart','wary','wave','weak', + 'weal','wean','weed','week','weld','well','welt','went','west','wide','wild','will','wilt', + 'wind','wine','wing','wink','wire','wise','wish','wisp','wolf','wood','wool','word','wore', + 'work','worm','wove', + 'yawn','year','yell','your', + 'zeal','zero','zest','zinc','zone','zoom', + // extra words needed for puzzle paths + 'rink','sire','silt','cord','wore', +]) + +export const WORDS_5: ReadonlySet = new Set([ + 'abide','above','abuse','adult','after','again','aisle','alarm','album','alert','alike', + 'align','alive','alley','allow','alone','aloud','altar','alter','angel','anger','angle', + 'angry','ankle','annoy','apple','apply','arena','argue','arise','armor','aroma','arose', + 'array','aside','asset','atone','attic','avoid','awake','award','aware','awful', + 'badge','badly','baker','bathe','beach','began','begin','being','below','bench','berry', + 'bible','birth','black','blade','blame','bland','blank','blast','blaze','bleak','bleed', + 'blend','bless','blind','block','blood','bloom','blown','board','bonus','boost','booth', + 'bored','bound','boxer','brace','braid','brain','brake','brand','brave','bread','break', + 'bride','brief','bring','brisk','brook','broom','broth','brown','brush','build','built', + 'burst','buyer', + 'cabin','candy','carry','catch','cause','cease','chain','chair','chalk','chaos','charm', + 'chase','cheap','check','cheek','cheer','chess','chest','chief','child','chord', + 'chose','civic','civil','claim','clash','class','clean','clear','clerk','click','climb', + 'cling','clock','close','cloth','cloud','coach','coast','coral','could','count', + 'court','cover','crack','craft','crane','crash','crazy','creak','cream','creek','crest', + 'crime','crisp','cross','crowd','crown','crush','curve', + 'daily','dance','dealt','decay','delay','dense','depth','devil','dirty','ditch', + 'dodge','doubt','draft','drain','drama','dream','dress','drift','drink','drive', + 'drone','drove','drown','dying', + 'eager','eagle','early','earth','eight','elite','empty','enemy','enjoy','enter','entry', + 'equal','essay','every','exact','exist','extra', + 'faint','faith','false','fancy','fatal','fault','feast','fence','field', + 'fifth','fight','final','first','fixed','flame','flash','fleet','flesh','flock','flood', + 'floor','flour','fluid','flute','focus','force','forge','forte','forty','found', + 'frame','fresh','front','frost','froze','fruit','fully', + 'ghost','given','glare','gloss','glove','going','grace','grade','grain','grand','grant', + 'grasp','grape','graze','great','green','greet','grief','groan','gross','group','grown', + 'guard','guide','guild','guile','guilt','guise', + 'habit','happy','harsh','haven','heart','heavy','hedge','hence','hinge', + 'hobby','honor','house','human','humor','hurry', + 'ideal','image','imply','infer','inner','input','issue', + 'judge','juice','juicy','jumbo', + 'karma','knack','kneel','knelt','knife','knock','known', + 'label','lance','large','laser','later','laugh','layer','learn','lease','leave','ledge', + 'level','light','liner','liver','local','lodge','logic','loose','lover','lower', + 'lucky','lunar','lunch', + 'magic','major','maker','manor','march','marry','match','mayor','media','mercy','merit', + 'metal','model','money','month','moral','motor','motto','mount','mouse','mouth','mover', + 'movie','music', + 'naive','naval','night','noble','noise','north','noted','novel', + 'ocean','offer','often','olive','order','other','outer', + 'paint','panic','party','pasta','patch','pause','peach','pearl','penny','perch', + 'phase','phone','photo','piano','piece','pilot','place','plain','plane', + 'plant','plate','plaza','plead','pluck','plumb','plume','point', + 'polar','pouch','pound','power','press','price','pride','prime','print','prior','prize', + 'probe','proof','prose','proud','prove','pulse','punch','pupil','purse', + 'queen','query','quick','quiet','quota','quote', + 'rabbi','radar','radio','raise','rally','ranch','range','rapid','ratio','reach','ready', + 'realm','rebel','refer','reign','relax','relay','reply','rider','right','rigid', + 'risky','rival','river','robin','robot','rocky','rouge','rough','round','route','royal','ruler', + 'saint','sauce','scale','scene','score','scout','seize','sense','serve', + 'seven','shade','shaft','shake','shall','shame','shape','share','shark','sharp', + 'shelf','shell','shift','shore','short','shout','since','sixth','sixty','skill', + 'skull','slate','slave','sleep','slice','slide','slope','small','smart','smell', + 'smile','smoke','snake','solar','solid','solve','sorry','sound','south','space','spare', + 'spark','speak','spear','speed','spite','split','spoke','spoon','sport','spray', + 'stain','stair','stake','stale','stalk','stall','stamp','stand','stank','stark','start', + 'state','stave','steel','steep','steer','stern','stick','stiff','still','sting','stock', + 'stomp','stone','stood','stoop','store','storm','story','stove','straw','stray','strip', + 'stuck','study','stump','stung','style','sugar','suite','sunny','super','surge', + 'swear','sweep','sweet','swept','swift','swirl','sword', + 'table','taste','teach','tense','theme','there','these','thick','thing','think','third', + 'thorn','those','three','threw','throw','thumb','tiger','tired','title', + 'today','token','total','touch','tough','towel','tower','toxic','track','trade', + 'trail','train','trait','tread','treat','trend','trial','tribe','trick', + 'tried','troop','trout','truce','truck','truly','trunk','trust','truth','tulip', + 'twice','twist','tying', + 'under','unify','union','unite','until','upper','upset','urban','usage','usual', + 'vague','valid','value','valve','vapor','verse','video','vigor','viral','vital','voice', + 'waste','watch','water','weary','weave','wedge','weigh','weird','whale','wheat','wheel', + 'where','which','while','white','whole','whose','witch','woman','women','world', + 'worry','worse','worst','worth','would','wound','write','wrong','wrote', + 'yield','young','youth','zebra', + // extra words needed for puzzle paths + 'stoke','clack','clank','creak', +]) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type Difficulty = 'easy' | 'medium' | 'hard' + +export type PuzzlePair = { + start: string + target: string + /** Maximum number of intermediate steps (not counting the start word) */ + maxSteps: number +} + +// --------------------------------------------------------------------------- +// Puzzle Pairs +// Paths verified manually: +// Easy (3 letters) — optimal ≤ 4 steps, maxSteps = 7 +// Medium (4 letters) — optimal ≤ 5 steps, maxSteps = 9 +// Hard (5 letters) — optimal ≤ 4 steps, maxSteps = 8 +// --------------------------------------------------------------------------- + +export const PUZZLES_EASY: readonly PuzzlePair[] = [ + // CAT→COT→COG→DOG + { start: 'CAT', target: 'DOG', maxSteps: 7 }, + // HOT→HAT→CAT→CAP + { start: 'HOT', target: 'CAP', maxSteps: 7 }, + // SIT→BIT→BIN→BUN + { start: 'SIT', target: 'BUN', maxSteps: 7 }, + // HAT→BAT→BAR→WAR + { start: 'HAT', target: 'WAR', maxSteps: 7 }, + // TOP→TIP→DIP→DIM + { start: 'TOP', target: 'DIM', maxSteps: 7 }, + // CAR→CAT→HAT→HIT + { start: 'CAR', target: 'HIT', maxSteps: 7 }, + // MAN→CAN→CAP→CAT + { start: 'MAN', target: 'CAT', maxSteps: 7 }, + // BAD→BAT→MAT→MAN + { start: 'BAD', target: 'MAN', maxSteps: 7 }, +] + +export const PUZZLES_MEDIUM: readonly PuzzlePair[] = [ + // COLD→CORD→WORD→WARD→WARM + { start: 'COLD', target: 'WARM', maxSteps: 9 }, + // LOVE→LIVE→LIME→LIKE + { start: 'LOVE', target: 'LIKE', maxSteps: 9 }, + // HEAD→HEAL→TEAL→TELL→TALL→TAIL + { start: 'HEAD', target: 'TAIL', maxSteps: 9 }, + // FIRE→HIRE→WIRE→WIDE→WIND + { start: 'FIRE', target: 'WIND', maxSteps: 9 }, + // RING→RINK→SINK→SING→SONG + { start: 'RING', target: 'SONG', maxSteps: 9 }, + // MINE→MANE→LANE→LAND→BAND→BOND→BOLD→GOLD + { start: 'MINE', target: 'GOLD', maxSteps: 10 }, + // MADE→MAKE→LAKE→LIKE→BIKE→BITE + { start: 'MADE', target: 'BITE', maxSteps: 9 }, + // DARK→DARE→DINE→FINE→WINE→WINE + { start: 'DARK', target: 'WINE', maxSteps: 9 }, +] + +export const PUZZLES_HARD: readonly PuzzlePair[] = [ + // STONE→STORE→STARE→SHARE + { start: 'STONE', target: 'SHARE', maxSteps: 8 }, + // SMART→START→STARK + { start: 'SMART', target: 'STARK', maxSteps: 8 }, + // STALE→STAKE→STOKE→STOVE + { start: 'STALE', target: 'STOVE', maxSteps: 8 }, + // CREAM→CREAK→BREAK→BREAD + { start: 'CREAM', target: 'BREAD', maxSteps: 8 }, + // DRIVE→DROVE→PROVE + { start: 'DRIVE', target: 'PROVE', maxSteps: 8 }, + // BRAVE→GRAVE→GRAPE→GRAZE + { start: 'BRAVE', target: 'GRAZE', maxSteps: 8 }, + // SHORE→STORE→STARE→STARK→STARK + { start: 'SHORE', target: 'STARK', maxSteps: 8 }, + // BLACK→SLACK→SLICK→STICK→STOCK→STORK? Hmm let me use a safer one + // STAND→BRAND→BRAID→BRAIN + { start: 'STAND', target: 'BRAIN', maxSteps: 8 }, +] + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Returns the word set for a given word length */ +export function getWordSet(length: number): ReadonlySet { + if (length === 3) return WORDS_3 + if (length === 4) return WORDS_4 + if (length === 5) return WORDS_5 + return new Set() +} + +/** Returns the puzzle list for a given difficulty */ +export function getPuzzles(difficulty: Difficulty): readonly PuzzlePair[] { + if (difficulty === 'easy') return PUZZLES_EASY + if (difficulty === 'medium') return PUZZLES_MEDIUM + return PUZZLES_HARD +} + +/** Picks a random puzzle from the list */ +export function getRandomPuzzle(difficulty: Difficulty): PuzzlePair { + const list = getPuzzles(difficulty) + return list[Math.floor(Math.random() * list.length)] +} + +/** Counts the number of differing letters between two same-length words */ +export function diffCount(a: string, b: string): number { + let count = 0 + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) count++ + } + return count +} + +/** Returns true if `word` is in the dictionary for its length */ +export function isValidWord(word: string): boolean { + return getWordSet(word.length).has(word.toLowerCase()) +} + +/** + * Returns true when: + * 1. `to` differs from `from` by exactly one letter + * 2. `to` is a valid dictionary word + */ +export function isValidStep(from: string, to: string): boolean { + if (from.length !== to.length) return false + const toUp = to.toUpperCase() + if (diffCount(from.toUpperCase(), toUp) !== 1) return false + return getWordSet(toUp.length).has(toUp) +} + +// --------------------------------------------------------------------------- +// BFS — used to generate hints and verify puzzle solvability +// --------------------------------------------------------------------------- + +function getNeighbors(word: string, wordSet: ReadonlySet): string[] { + const neighbors: string[] = [] + // work in lowercase to match the dictionary + const lower = word.toLowerCase() + const chars = lower.split('') + for (let i = 0; i < chars.length; i++) { + const original = chars[i] + for (let c = 97; c <= 122; c++) { // a–z + const letter = String.fromCharCode(c) + if (letter === original) continue + chars[i] = letter + const candidate = chars.join('') + if (wordSet.has(candidate)) neighbors.push(candidate.toUpperCase()) + chars[i] = original + } + } + return neighbors +} + +/** + * BFS shortest-path finder. + * Returns the full path [start, ..., target] or null if unreachable. + * Both words must be uppercase. + */ +export function bfs( + start: string, + target: string, + wordSet: ReadonlySet, +): string[] | null { + // normalise to uppercase for the path output, lowercase for dict lookup + const startUp = start.toUpperCase() + const targetUp = target.toUpperCase() + if (startUp === targetUp) return [startUp] + + const queue: Array<[string, string[]]> = [[startUp, [startUp]]] + const visited = new Set([startUp]) + + while (queue.length > 0) { + const [current, path] = queue.shift()! + for (const neighbor of getNeighbors(current, wordSet)) { + if (neighbor === targetUp) return [...path, neighbor] + if (!visited.has(neighbor)) { + visited.add(neighbor) + queue.push([neighbor, [...path, neighbor]]) + } + } + } + return null +} + +/** + * Returns the next word in the optimal BFS path from `current` to `target`. + * Returns null if no path exists. + */ +export function getHint( + current: string, + target: string, +): string | null { + const wordSet = getWordSet(current.length) + const path = bfs(current.toUpperCase(), target.toUpperCase(), wordSet) + if (!path || path.length < 2) return null + return path[1] +} From c0bf61985329b504fadf4837feaabd9fc28aae1b Mon Sep 17 00:00:00 2001 From: lautyscanio Date: Tue, 5 May 2026 09:27:26 -0300 Subject: [PATCH 2/5] fix: validate difficulty search param before use Prevents invalid URL values from causing MAX_LIVES[difficulty] to return undefined, which would set lives=NaN and maxLives=0, breaking the entire life system. Now validates against the explicit allowlist ['easy','medium','hard'] and falls back to 'medium' on any unexpected value. --- src/app/game/word-ladder/play/WordLadderGame.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/game/word-ladder/play/WordLadderGame.tsx b/src/app/game/word-ladder/play/WordLadderGame.tsx index 3b10240..59d81dc 100644 --- a/src/app/game/word-ladder/play/WordLadderGame.tsx +++ b/src/app/game/word-ladder/play/WordLadderGame.tsx @@ -57,10 +57,19 @@ const MAX_LIVES: Record = { // Component // --------------------------------------------------------------------------- +const VALID_DIFFICULTIES: readonly Difficulty[] = ['easy', 'medium', 'hard'] + +function parseDifficulty(value: string | null): Difficulty { + if (value && (VALID_DIFFICULTIES as string[]).includes(value)) { + return value as Difficulty + } + return 'medium' +} + export default function WordLadderGame() { const router = useRouter() const searchParams = useSearchParams() - const difficulty = (searchParams.get('difficulty') ?? 'medium') as Difficulty + const difficulty = parseDifficulty(searchParams.get('difficulty')) const [puzzle, setPuzzle] = useState(null) const [chain, setChain] = useState([]) From 476d5238ced6ad29477a39552ecff64b9263bd75 Mon Sep 17 00:00:00 2001 From: lautyscanio Date: Tue, 5 May 2026 09:30:36 -0300 Subject: [PATCH 3/5] fix: address 3 codeant review issues - fix(hint): pass used chain words to BFS so hints never suggest an already-used word (getHint now accepts usedWords param) - fix(css): add flex-wrap + min-width:0 to .chainRow to prevent horizontal overflow on 320px screens in hard mode (5 tiles) - fix(css): hide .startBadge and shrink .chainIndex at <=360px to free space for letter tiles on the smallest devices - note: difficulty param validation was already fixed in c0bf619 --- src/app/game/word-ladder/play/WordLadderGame.tsx | 3 ++- src/app/game/word-ladder/play/page.module.css | 12 ++++++++++++ src/app/game/word-ladder/wordList.ts | 12 +++++++++--- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/app/game/word-ladder/play/WordLadderGame.tsx b/src/app/game/word-ladder/play/WordLadderGame.tsx index 59d81dc..fa840d2 100644 --- a/src/app/game/word-ladder/play/WordLadderGame.tsx +++ b/src/app/game/word-ladder/play/WordLadderGame.tsx @@ -170,7 +170,8 @@ export default function WordLadderGame() { const handleHint = () => { if (!puzzle || status !== 'playing') return - const next = getHint(currentWord, puzzle.target) + // Pass the full chain so BFS avoids already-used words + const next = getHint(currentWord, puzzle.target, chain) if (next) { setHint(next) setHintsUsed((h: number) => h + 1) diff --git a/src/app/game/word-ladder/play/page.module.css b/src/app/game/word-ladder/play/page.module.css index c2bd4b3..662cf45 100644 --- a/src/app/game/word-ladder/play/page.module.css +++ b/src/app/game/word-ladder/play/page.module.css @@ -266,6 +266,8 @@ border-radius: 12px; background: transparent; transition: background 0.2s; + flex-wrap: wrap; /* prevent overflow on narrow screens */ + min-width: 0; /* allow children to shrink */ } .chainStart { @@ -603,4 +605,14 @@ .wordInput { width: 130px; } + + /* Hide non-critical labels on very small screens to prevent chain overflow */ + .startBadge { + display: none; + } + + .chainIndex { + width: 18px; + font-size: 0.68rem; + } } diff --git a/src/app/game/word-ladder/wordList.ts b/src/app/game/word-ladder/wordList.ts index c474461..2eaf74e 100644 --- a/src/app/game/word-ladder/wordList.ts +++ b/src/app/game/word-ladder/wordList.ts @@ -361,11 +361,13 @@ function getNeighbors(word: string, wordSet: ReadonlySet): string[] { * BFS shortest-path finder. * Returns the full path [start, ..., target] or null if unreachable. * Both words must be uppercase. + * @param usedWords Optional set of already-used uppercase words to exclude from the search. */ export function bfs( start: string, target: string, wordSet: ReadonlySet, + usedWords: ReadonlySet = new Set(), ): string[] | null { // normalise to uppercase for the path output, lowercase for dict lookup const startUp = start.toUpperCase() @@ -373,7 +375,8 @@ export function bfs( if (startUp === targetUp) return [startUp] const queue: Array<[string, string[]]> = [[startUp, [startUp]]] - const visited = new Set([startUp]) + // Seed visited with already-used words so BFS won't route through them + const visited = new Set([startUp, ...usedWords]) while (queue.length > 0) { const [current, path] = queue.shift()! @@ -389,15 +392,18 @@ export function bfs( } /** - * Returns the next word in the optimal BFS path from `current` to `target`. + * Returns the next word in the optimal BFS path from `current` to `target`, + * avoiding any words already in the player's chain. * Returns null if no path exists. */ export function getHint( current: string, target: string, + usedWords: readonly string[] = [], ): string | null { const wordSet = getWordSet(current.length) - const path = bfs(current.toUpperCase(), target.toUpperCase(), wordSet) + const usedSet = new Set(usedWords.map((w) => w.toUpperCase())) + const path = bfs(current.toUpperCase(), target.toUpperCase(), wordSet, usedSet) if (!path || path.length < 2) return null return path[1] } From 5d15c7ae4cc84be3a6ab4314df0814589caaadf3 Mon Sep 17 00:00:00 2001 From: lautyscanio Date: Tue, 5 May 2026 09:38:41 -0300 Subject: [PATCH 4/5] refactor: apply best practices to WordLadderGame - useCallback on handleSubmit, handleHint, handleKeyDown, triggerError to prevent unnecessary re-renders - useMemo for usedWordSet (Set) replacing O(n) chain.includes() with O(1) Set.has() on every submission - Timeout cleanup: shakeTimerRef + lostTimerRef track pending timeouts, cleared on unmount and on initPuzzle to prevent stale state updates - setLives uses functional updater (prev => next) to avoid stale closure over lives value - parseDifficulty hardened: checks value !== null explicitly before the includes() call - Constants (MAX_LIVES, ERROR_MESSAGES, VALID_DIFFICULTIES) moved to module scope so they are not recreated on every render - initPuzzle cancels pending timers before resetting state --- .../game/word-ladder/play/WordLadderGame.tsx | 118 +++++++++++------- 1 file changed, 71 insertions(+), 47 deletions(-) diff --git a/src/app/game/word-ladder/play/WordLadderGame.tsx b/src/app/game/word-ladder/play/WordLadderGame.tsx index fa840d2..5061153 100644 --- a/src/app/game/word-ladder/play/WordLadderGame.tsx +++ b/src/app/game/word-ladder/play/WordLadderGame.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect, useRef, useCallback } from 'react' +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import styles from './page.module.css' import { @@ -27,12 +27,15 @@ type ErrorKind = | null // --------------------------------------------------------------------------- -// Helpers +// Constants // --------------------------------------------------------------------------- -/** Returns an array describing which positions changed vs the previous word */ -function getChangedPositions(prev: string, current: string): boolean[] { - return current.split('').map((ch, i) => ch !== prev[i]) +const VALID_DIFFICULTIES: readonly Difficulty[] = ['easy', 'medium', 'hard'] + +const MAX_LIVES: Record = { + easy: 5, + medium: 3, + hard: 2, } const ERROR_MESSAGES: Record, string> = { @@ -44,28 +47,26 @@ const ERROR_MESSAGES: Record, string> = { } // --------------------------------------------------------------------------- -// Lives per difficulty +// Helpers // --------------------------------------------------------------------------- -const MAX_LIVES: Record = { - easy: 5, - medium: 3, - hard: 2, +/** Returns an array describing which positions changed vs the previous word */ +function getChangedPositions(prev: string, current: string): boolean[] { + return current.split('').map((ch, i) => ch !== prev[i]) } -// --------------------------------------------------------------------------- -// Component -// --------------------------------------------------------------------------- - -const VALID_DIFFICULTIES: readonly Difficulty[] = ['easy', 'medium', 'hard'] - +/** Validates the difficulty search param against the allowlist */ function parseDifficulty(value: string | null): Difficulty { - if (value && (VALID_DIFFICULTIES as string[]).includes(value)) { + if (value !== null && (VALID_DIFFICULTIES as string[]).includes(value)) { return value as Difficulty } return 'medium' } +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + export default function WordLadderGame() { const router = useRouter() const searchParams = useSearchParams() @@ -78,14 +79,30 @@ export default function WordLadderGame() { const [error, setError] = useState(null) const [hint, setHint] = useState(null) const [hintsUsed, setHintsUsed] = useState(0) - const [lives, setLives] = useState(MAX_LIVES[difficulty]) + const [lives, setLives] = useState(() => MAX_LIVES[difficulty]) const [shake, setShake] = useState(false) + const inputRef = useRef(null) + // Refs to hold pending timeout IDs so we can clean them up + const shakeTimerRef = useRef | null>(null) + const lostTimerRef = useRef | null>(null) const maxLives = MAX_LIVES[difficulty] - // Initialise a new puzzle + // ── Cleanup timers on unmount ─────────────────────────────────────────── + useEffect(() => { + return () => { + if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current) + if (lostTimerRef.current) clearTimeout(lostTimerRef.current) + } + }, []) + + // ── Initialise / reset a puzzle ───────────────────────────────────────── const initPuzzle = useCallback(() => { + // Cancel any pending timers from a previous game + if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current) + if (lostTimerRef.current) clearTimeout(lostTimerRef.current) + const p = getRandomPuzzle(difficulty) setPuzzle(p) setChain([p.start]) @@ -101,24 +118,36 @@ export default function WordLadderGame() { initPuzzle() }, [initPuzzle]) + // Focus the input whenever the player can act useEffect(() => { if (status === 'playing') inputRef.current?.focus() }, [status, chain]) - // ── Derived ───────────────────────────────────────────────────────────── + // ── Derived values ─────────────────────────────────────────────────────── const stepsUsed = chain.length - 1 const stepsLeft = puzzle ? puzzle.maxSteps - stepsUsed : 0 const currentWord = chain[chain.length - 1] ?? '' + // O(1) lookup for already-used words — avoids O(n) array scan on every submit + const usedWordSet = useMemo(() => new Set(chain), [chain]) + + // ── Error helper ───────────────────────────────────────────────────────── + const triggerError = useCallback((kind: ErrorKind) => { + setError(kind) + setShake(true) + if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current) + shakeTimerRef.current = setTimeout(() => setShake(false), 500) + }, []) + // ── Submit a word ──────────────────────────────────────────────────────── - const handleSubmit = () => { + const handleSubmit = useCallback(() => { if (!puzzle || status !== 'playing') return const word = input.trim().toUpperCase() if (word === '') return setHint(null) - // ── Validations that do NOT cost a life ── + // ── Validations that do NOT cost a life ────────────────────────────── if (word.length !== puzzle.start.length) { triggerError('wrong-length') return @@ -131,22 +160,26 @@ export default function WordLadderGame() { triggerError('multiple-changes') return } - if (chain.includes(word)) { + if (usedWordSet.has(word)) { triggerError('already-used') return } - // ── Validation that COSTS a life: not in dictionary ── + // ── Dictionary check — costs one life ──────────────────────────────── if (!isValidWord(word)) { triggerError('not-a-word') - const newLives = lives - 1 - setLives(newLives) - if (newLives <= 0) { - setTimeout(() => setStatus('lost'), 400) - } + setLives((prev) => { + const next = prev - 1 + if (next <= 0) { + if (lostTimerRef.current) clearTimeout(lostTimerRef.current) + lostTimerRef.current = setTimeout(() => setStatus('lost'), 400) + } + return next + }) return } + // ── Valid move ─────────────────────────────────────────────────────── const newChain = [...chain, word] setChain(newChain) setInput('') @@ -160,29 +193,20 @@ export default function WordLadderGame() { if (newChain.length - 1 >= puzzle.maxSteps) { setStatus('lost') } - } + }, [puzzle, status, input, currentWord, usedWordSet, chain, triggerError]) - const triggerError = (kind: ErrorKind) => { - setError(kind) - setShake(true) - setTimeout(() => setShake(false), 500) - } - - const handleHint = () => { + // ── Hint ───────────────────────────────────────────────────────────────── + const handleHint = useCallback(() => { if (!puzzle || status !== 'playing') return - // Pass the full chain so BFS avoids already-used words + // Pass the full chain so BFS never suggests an already-used word const next = getHint(currentWord, puzzle.target, chain) - if (next) { - setHint(next) - setHintsUsed((h: number) => h + 1) - } else { - setHint('no-path') - } - } + setHint(next ?? 'no-path') + setHintsUsed((h) => h + 1) + }, [puzzle, status, currentWord, chain]) - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') handleSubmit() - } + }, [handleSubmit]) // ── Render ─────────────────────────────────────────────────────────────── if (!puzzle) { From 7c9c16ad8e3e9beec38dc554a64c256afd75467d Mon Sep 17 00:00:00 2001 From: lautyscanio Date: Tue, 5 May 2026 09:43:03 -0300 Subject: [PATCH 5/5] fix: address 5 coderabbitai review issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(css): rename camelCase keyframe animations to kebab-case (popIn → pop-in, fadeIn → fade-in) to satisfy Stylelint keyframes-name-pattern rule; updated all animation: references - fix(data): align MINE→GOLD maxSteps from 10 to 9 so it matches the 'Up to 9 steps' advertised on the Medium difficulty card - fix(comments): clean up PUZZLES_HARD inline comments • SHORE path: remove duplicate STARK at the end • STAND path: remove stray dev note and fix invalid STAND→BRAND (changed 2 letters) to correct path STAND→BLAND→BRAND→BRAID→BRAIN - fix(isValidStep): was always returning false for valid words because it checked uppercase against a lowercase dictionary; now delegates to isValidWord() for consistent case handling - note: difficulty param validation was already fixed in c0bf619 --- src/app/game/word-ladder/play/page.module.css | 14 +++++++------- src/app/game/word-ladder/wordList.ts | 19 ++++++++++--------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/app/game/word-ladder/play/page.module.css b/src/app/game/word-ladder/play/page.module.css index 662cf45..df397e3 100644 --- a/src/app/game/word-ladder/play/page.module.css +++ b/src/app/game/word-ladder/play/page.module.css @@ -241,10 +241,10 @@ background: linear-gradient(135deg, #22c55e, #16a34a) !important; border-color: #16a34a !important; color: white !important; - animation: popIn 0.4s ease forwards; + animation: pop-in 0.4s ease forwards; } -@keyframes popIn { +@keyframes pop-in { 0% { transform: scale(0.8); } 60% { transform: scale(1.12); } 100% { transform: scale(1); } @@ -280,7 +280,7 @@ } .chainWon .tile { - animation: popIn 0.4s ease forwards; + animation: pop-in 0.4s ease forwards; } .chainIndex { @@ -313,10 +313,10 @@ font-size: 0.95rem; color: #92400e; margin-bottom: 16px; - animation: fadeIn 0.3s ease; + animation: fade-in 0.3s ease; } -@keyframes fadeIn { +@keyframes fade-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } @@ -405,7 +405,7 @@ font-size: 0.9rem; font-weight: 600; margin-bottom: 12px; - animation: fadeIn 0.2s ease; + animation: fade-in 0.2s ease; } /* ── Shake animation ─────────────────────────────────────────── */ @@ -427,7 +427,7 @@ border-radius: 20px; padding: 32px 24px; margin-top: 20px; - animation: fadeIn 0.5s ease; + animation: fade-in 0.5s ease; } .resultCard[data-result='won'] { diff --git a/src/app/game/word-ladder/wordList.ts b/src/app/game/word-ladder/wordList.ts index 2eaf74e..32db343 100644 --- a/src/app/game/word-ladder/wordList.ts +++ b/src/app/game/word-ladder/wordList.ts @@ -256,7 +256,7 @@ export const PUZZLES_MEDIUM: readonly PuzzlePair[] = [ // RING→RINK→SINK→SING→SONG { start: 'RING', target: 'SONG', maxSteps: 9 }, // MINE→MANE→LANE→LAND→BAND→BOND→BOLD→GOLD - { start: 'MINE', target: 'GOLD', maxSteps: 10 }, + { start: 'MINE', target: 'GOLD', maxSteps: 9 }, // MADE→MAKE→LAKE→LIKE→BIKE→BITE { start: 'MADE', target: 'BITE', maxSteps: 9 }, // DARK→DARE→DINE→FINE→WINE→WINE @@ -276,10 +276,9 @@ export const PUZZLES_HARD: readonly PuzzlePair[] = [ { start: 'DRIVE', target: 'PROVE', maxSteps: 8 }, // BRAVE→GRAVE→GRAPE→GRAZE { start: 'BRAVE', target: 'GRAZE', maxSteps: 8 }, - // SHORE→STORE→STARE→STARK→STARK + // SHORE→STORE→STARE→STARK { start: 'SHORE', target: 'STARK', maxSteps: 8 }, - // BLACK→SLACK→SLICK→STICK→STOCK→STORK? Hmm let me use a safer one - // STAND→BRAND→BRAID→BRAIN + // STAND→BLAND→BRAND→BRAID→BRAIN { start: 'STAND', target: 'BRAIN', maxSteps: 8 }, ] @@ -324,14 +323,16 @@ export function isValidWord(word: string): boolean { /** * Returns true when: - * 1. `to` differs from `from` by exactly one letter - * 2. `to` is a valid dictionary word + * 1. `to` has the same length as `from` + * 2. `to` differs from `from` by exactly one letter + * 3. `to` is a valid dictionary word + * + * Delegates the dictionary check to isValidWord so casing is always consistent. */ export function isValidStep(from: string, to: string): boolean { if (from.length !== to.length) return false - const toUp = to.toUpperCase() - if (diffCount(from.toUpperCase(), toUp) !== 1) return false - return getWordSet(toUp.length).has(toUp) + if (diffCount(from.toUpperCase(), to.toUpperCase()) !== 1) return false + return isValidWord(to) } // ---------------------------------------------------------------------------