Skip to content

Commit f2ea847

Browse files
committed
feat: word ladder game
1 parent cf889a9 commit f2ea847

File tree

4 files changed

+330
-0
lines changed

4 files changed

+330
-0
lines changed

public/apps/apps.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@
102102
"title": "Mastermind",
103103
"description": "Play the classic code-breaking game of Mastermind (Bulls and Cows).",
104104
"icon": "PuzzlePieceIcon"
105+
},
106+
{
107+
"slug": "word-ladder",
108+
"to": "/apps/word-ladder",
109+
"title": "Word Ladder",
110+
"description": "Transform one word into another by changing one letter at a time.",
111+
"icon": "LadderSimpleIcon"
105112
}
106113
]
107114
},

src/components/AnimatedRoutes.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import StopwatchAppPage from '../pages/StopwatchAppPage'; // Import StopwatchApp
5555
import PomodoroTimerPage from '../pages/apps/PomodoroTimerPage';
5656
import MorseCodeTranslatorPage from '../pages/apps/MorseCodeTranslatorPage';
5757
import MastermindPage from '../pages/apps/MastermindPage';
58+
import WordLadderPage from '../pages/apps/WordLadderPage'; // Import WordLadderPage
5859
import SettingsPage from '../pages/SettingsPage';
5960

6061
import UsefulLinksPage from '../pages/UsefulLinksPage';
@@ -544,6 +545,10 @@ function AnimatedRoutes() {
544545
path="/apps::mm"
545546
element={<Navigate to="/apps/mastermind" replace />}
546547
/>
548+
<Route
549+
path="/apps::wl"
550+
element={<Navigate to="/apps/word-ladder" replace />}
551+
/>
547552
{/* End of hardcoded redirects */}
548553
<Route
549554
path="/apps/ip"
@@ -1064,6 +1069,20 @@ function AnimatedRoutes() {
10641069
</motion.div>
10651070
}
10661071
/>
1072+
<Route
1073+
path="/apps/word-ladder"
1074+
element={
1075+
<motion.div
1076+
initial="initial"
1077+
animate="in"
1078+
exit="out"
1079+
variants={pageVariants}
1080+
transition={pageTransition}
1081+
>
1082+
<WordLadderPage />
1083+
</motion.div>
1084+
}
1085+
/>
10671086
{/* D&D specific 404 page */}
10681087
<Route
10691088
path="/stories/*"

src/pages/apps/WordLadderPage.js

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
1+
import React, { useState, useEffect, useRef, useContext } from 'react';
2+
import { Link } from 'react-router-dom';
3+
import { ArrowLeftIcon, LightbulbIcon, ArrowCounterClockwiseIcon, LadderSimpleIcon } from '@phosphor-icons/react';
4+
import useSeo from '../../hooks/useSeo';
5+
import { ToastContext } from '../../context/ToastContext';
6+
7+
// Placeholder dictionary - will be moved or loaded dynamically later
8+
const DICTIONARY = new Set([
9+
'able', 'acid', 'aged', 'also', 'area', 'army', 'away', 'baby', 'back', 'ball',
10+
'band', 'bank', 'bare', 'bark', 'barn', 'base', 'bath', 'bear', 'beat', 'bell',
11+
'bend', 'best', 'bill', 'bird', 'bite', 'black', 'blow', 'blue', 'boat', 'body',
12+
'bomb', 'bond', 'bone', 'born', 'boss', 'both', 'bowl', 'boys', 'bulk', 'burn',
13+
'bush', 'busy', 'call', 'calm', 'came', 'camp', 'card', 'care', 'case', 'cash',
14+
'cast', 'cell', 'cent', 'chef', 'chip', 'city', 'club', 'coal', 'coat',
15+
'code', 'cold', 'come', 'cook', 'cool', 'copy', 'core', 'cost', 'crew', 'crop', 'dark',
16+
'data', 'dawn', 'dead', 'deal', 'dean', 'dear', 'deep', 'deny', 'desk', 'dial',
17+
'diet', 'disc', 'dish', 'does', 'done', 'door', 'down', 'draw', 'drew', 'drop',
18+
'drug', 'dump', 'dust', 'earn', 'east', 'easy', 'edge', 'else', 'even', 'ever',
19+
'face', 'fact', 'fail', 'fair', 'fall', 'farm', 'fast', 'fear', 'feed', 'feel',
20+
'feet', 'fell', 'felt', 'file', 'fill', 'film', 'find', 'fine', 'fire', 'firm',
21+
'fish', 'five', 'flat', 'flex', 'flow', 'foot', 'form', 'fort', 'four', 'free',
22+
'from', 'full', 'fund', 'gain', 'game', 'gate', 'gave', 'gear', 'gene', 'gift',
23+
'girl', 'give', 'glad', 'goal', 'goes', 'gold', 'gone', 'good', 'gray', 'grow',
24+
'grew', 'hair', 'half', 'hall', 'hand', 'hang', 'hard', 'have', 'head', 'hear',
25+
'heat', 'held', 'hell', 'help', 'here', 'hero', 'hide', 'high', 'hill', 'hire',
26+
'hold', 'hole', 'home', 'hope', 'host', 'hour', 'huge', 'hung', 'hunt', 'hurt',
27+
'idea', 'inch', 'into', 'iron', 'item', 'join', 'jump', 'just', 'keep', 'kept',
28+
'kick', 'kill', 'kind', 'king', 'knee', 'knew', 'know', 'lack', 'lady', 'laid',
29+
'land', 'last', 'late', 'lead', 'leak', 'lean', 'left', 'lend', 'less',
30+
'life', 'lift', 'like', 'limp', 'line', 'link', 'live', 'load', 'long', 'look',
31+
'lost', 'loud', 'love', 'lows', 'luck', 'made', 'mail', 'main', 'make', 'male',
32+
'many', 'mark', 'mass', 'mean', 'meet', 'menu', 'mere', 'mike', 'mile', 'milk',
33+
'mill', 'mind', 'mine', 'miss', 'mode', 'mood', 'moon', 'more', 'most', 'move',
34+
'much', 'must', 'name', 'near', 'neck', 'need', 'news', 'next', 'nice', 'nine',
35+
'none', 'nose', 'note', 'okay', 'once', 'only', 'open', 'onto', 'oral', 'over',
36+
'pace', 'pack', 'page', 'paid', 'pain', 'pair', 'pale', 'palm', 'park', 'part',
37+
'pass', 'past', 'path', 'peak', 'pick', 'pile', 'pill', 'pipe', 'plan', 'play',
38+
'plot', 'plug', 'plus', 'poem', 'poet', 'pole', 'poll', 'pool', 'poor', 'port',
39+
'post', 'pull', 'pure', 'push', 'race', 'raid', 'rail', 'rain', 'rank', 'rare',
40+
'rate', 'read', 'real', 'rear', 'rely', 'rent', 'rest', 'rice', 'rich', 'ride',
41+
'ring', 'rise', 'risk', 'road', 'rock', 'role', 'roll', 'roof', 'room', 'root',
42+
'rose', 'rule', 'runs', 'rush', 'safe', 'said', 'sail', 'sale', 'salt', 'same',
43+
'sand', 'save', 'says', 'scan', 'seat', 'seed', 'seek', 'seem', 'seen', 'self',
44+
'sell', 'send', 'sense', 'sent', 'sets', 'ship', 'shoe', 'shop', 'shot', 'show',
45+
'shut', 'sick', 'side', 'sign', 'sing', 'sink', 'site', 'size', 'skin', 'slip',
46+
'slow', 'snap', 'snow', 'soft', 'soil', 'sold', 'some', 'song', 'soon', 'sort',
47+
'soul', 'soup', 'spot', 'star', 'stay', 'step', 'stop', 'such', 'suit',
48+
'sure', 'take', 'talk', 'tall', 'tape', 'task', 'team', 'tear', 'tell', 'tend',
49+
'tens', 'test', 'text', 'than', 'that', 'them', 'then', 'they', 'thin', 'this',
50+
'thus', 'tied', 'till', 'time', 'tiny', 'tire', 'told', 'tone', 'took', 'tool',
51+
'toss', 'tour', 'town', 'tree', 'trip', 'true', 'turn', 'type', 'unit',
52+
'upon', 'used', 'user', 'vary', 'vast', 'very', 'view', 'vote', 'wage', 'wait',
53+
'walk', 'wall', 'want', 'warm', 'warn', 'wash', 'wave', 'ways', 'weak', 'wear',
54+
'week', 'well', 'went', 'were', 'west', 'what', 'when', 'whom', 'wide', 'wife',
55+
'wild', 'will', 'wind', 'wine', 'wing', 'wise', 'wish', 'with', 'wood',
56+
'word', 'wore', 'work', 'yard', 'yeah', 'year', 'yell', 'your', 'zero', 'zone'
57+
]);
58+
59+
const generateRandomWord = (length) => {
60+
const words = Array.from(DICTIONARY).filter(word => word.length === length);
61+
if (words.length === 0) return '';
62+
return words[Math.floor(Math.random() * words.length)];
63+
};
64+
65+
const isOneLetterDifferent = (word1, word2) => {
66+
if (word1.length !== word2.length) return false;
67+
let diffCount = 0;
68+
for (let i = 0; i < word1.length; i++) {
69+
if (word1[i] !== word2[i]) {
70+
diffCount++;
71+
}
72+
}
73+
return diffCount === 1;
74+
};
75+
76+
const WordLadderPage = () => {
77+
useSeo({
78+
title: 'Word Ladder | Fezcodex',
79+
description: 'A fun word transformation game where you change one letter at a time.',
80+
keywords: ['Fezcodex', 'word ladder', 'word game', 'puzzle', 'logic game'],
81+
ogTitle: 'Word Ladder | Fezcodex',
82+
ogDescription: 'A fun word transformation game where you change one letter at a time.',
83+
ogImage: 'https://fezcode.github.io/logo512.png',
84+
twitterCard: 'summary_large_image',
85+
twitterTitle: 'Word Ladder | Fezcodex',
86+
twitterDescription: 'A fun word transformation game where you change one letter at a time.',
87+
twitterImage: 'https://fezcode.github.io/logo512.png',
88+
});
89+
90+
const { addToast } = useContext(ToastContext);
91+
92+
const [startWord, setStartWord] = useState('');
93+
const [endWord, setEndWord] = useState('');
94+
const [currentGuess, setCurrentGuess] = useState('');
95+
const [ladder, setLadder] = useState([]);
96+
const [gameStatus, setGameStatus] = useState('playing'); // 'playing', 'won', 'lost'
97+
const [message, setMessage] = useState('');
98+
99+
const inputRef = useRef(null);
100+
101+
const initGame = (wordLength = 4) => {
102+
let newStartWord = generateRandomWord(wordLength);
103+
let newEndWord = generateRandomWord(wordLength);
104+
105+
// Ensure start and end words are different and a path might exist (basic check)
106+
while (newStartWord === newEndWord || !newStartWord || !newEndWord || !hasPath(newStartWord, newEndWord, DICTIONARY)) {
107+
newStartWord = generateRandomWord(wordLength);
108+
newEndWord = generateRandomWord(wordLength);
109+
}
110+
111+
setStartWord(newStartWord);
112+
setEndWord(newEndWord);
113+
setLadder([newStartWord]);
114+
setGameStatus('playing');
115+
setMessage('');
116+
setCurrentGuess('');
117+
if (inputRef.current) {
118+
inputRef.current.focus();
119+
}
120+
};
121+
122+
useEffect(() => {
123+
initGame(4); // Always initialize with 4-letter words
124+
}, []); // Empty dependency array means it runs once on mount
125+
126+
// Simple BFS to check if a path exists (expensive for large dictionaries)
127+
const hasPath = (start, end, dict) => {
128+
const queue = [{ word: start, path: [start] }];
129+
const visited = new Set([start]);
130+
131+
while (queue.length > 0) {
132+
const { word, path } = queue.shift();
133+
134+
if (word === end) return true;
135+
136+
for (const dictWord of dict) {
137+
if (!visited.has(dictWord) && isOneLetterDifferent(word, dictWord)) {
138+
visited.add(dictWord);
139+
queue.push({ word: dictWord, path: [...path, dictWord] });
140+
}
141+
}
142+
}
143+
return false;
144+
};
145+
146+
const handleGuessSubmit = (e) => {
147+
e.preventDefault();
148+
const guess = currentGuess.toLowerCase().trim();
149+
150+
if (!guess) {
151+
addToast({ message: 'Please enter a word.', type: 'error' });
152+
return;
153+
}
154+
155+
if (guess.length !== startWord.length) {
156+
addToast({ message: `Your guess must be ${startWord.length} letters long.`, type: 'error' });
157+
return;
158+
}
159+
160+
if (ladder.includes(guess)) {
161+
addToast({ message: 'You already have this word in your ladder.', type: 'warning' });
162+
return;
163+
}
164+
165+
if (!DICTIONARY.has(guess)) {
166+
addToast({ message: 'Not a valid word in our dictionary.', type: 'error' });
167+
return;
168+
}
169+
170+
const lastWordInLadder = ladder[ladder.length - 1];
171+
if (!isOneLetterDifferent(lastWordInLadder, guess)) {
172+
addToast({ message: `Your guess must differ by only one letter from "${lastWordInLadder}".`, type: 'error' });
173+
return;
174+
}
175+
176+
const newLadder = [...ladder, guess];
177+
setLadder(newLadder);
178+
setCurrentGuess('');
179+
180+
if (guess === endWord) {
181+
setGameStatus('won');
182+
setMessage(`Congratulations! You've completed the word ladder in ${newLadder.length - 1} steps!`);
183+
addToast({ message: 'You won!', type: 'success' });
184+
} else {
185+
setMessage('');
186+
}
187+
if (inputRef.current) {
188+
inputRef.current.focus();
189+
}
190+
};
191+
192+
const handleResetGame = () => {
193+
initGame(4);
194+
};
195+
196+
const gameControlsDisabled = gameStatus !== 'playing';
197+
198+
return (
199+
<div className="py-16 sm:py-24">
200+
<div className="mx-auto max-w-7xl px-6 lg:px-8 text-gray-300">
201+
<Link to="/apps" className="text-article hover:underline flex items-center justify-center gap-2 text-lg mb-4">
202+
<ArrowLeftIcon size={24} /> Back to Apps
203+
</Link>
204+
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl mb-4 flex items-center justify-center">
205+
<span className="codex-color">fc</span>
206+
<span className="separator-color">::</span>
207+
<span className="apps-color">apps</span>
208+
<span className="separator-color">::</span>
209+
<span className="single-app-color">wl</span>
210+
</h1>
211+
<hr className="border-gray-700" />
212+
<div className="flex justify-center items-center mt-16">
213+
<div className="bg-app-alpha-10 border-app-alpha-50 text-app hover:bg-app/15 group border rounded-lg shadow-2xl p-6 flex flex-col justify-between relative transform transition-all duration-300 ease-in-out scale-105 overflow-hidden h-full w-full max-w-2xl">
214+
<div className="absolute top-0 left-0 w-full h-full opacity-10" style={{ backgroundImage: 'radial-gradient(circle, white 1px, transparent 1px)', backgroundSize: '10px 10px' }}></div>
215+
<div className="relative z-10 p-4">
216+
<h2 className="text-3xl font-arvo font-normal mb-2 text-app text-center">Word Ladder</h2>
217+
<p className="text-center text-app-light mb-4">
218+
Transform <span className="font-semibold text-purple-600 dark:text-purple-400 text-lg">{startWord}</span> to{' '}
219+
<span className="font-semibold text-purple-600 dark:text-purple-400 text-lg">{endWord}</span> by changing one letter at a time.
220+
Each intermediate word must be a valid English word.
221+
</p>
222+
<hr className="border-gray-700 mb-6" />
223+
<div className="bg-gray-900/50 border border-app-alpha-50 rounded-md p-4 mb-6">
224+
<h3 className="text-xl font-bold mb-4 text-app-light text-center">Your Ladder:</h3>
225+
<div className="flex flex-wrap gap-2 justify-center">
226+
{ladder.map((word, index) => (
227+
<span
228+
key={index}
229+
className={`px-4 py-2 rounded-full text-lg font-mono shadow-md
230+
${index === 0 ? 'bg-green-700 text-green-100' : ''}
231+
${index === ladder.length - 1 && gameStatus === 'won' ? 'bg-yellow-700 text-yellow-100' : ''}
232+
${index > 0 && !(index === ladder.length - 1 && gameStatus === 'won') ? 'bg-blue-700 text-blue-100' : ''}
233+
`}
234+
>
235+
{word}
236+
</span>
237+
))}
238+
</div>
239+
</div>
240+
241+
{!gameControlsDisabled && (
242+
<form onSubmit={handleGuessSubmit} className="flex flex-col sm:flex-row gap-4 mb-6">
243+
<input
244+
ref={inputRef}
245+
type="text"
246+
value={currentGuess}
247+
onChange={(e) => setCurrentGuess(e.target.value)}
248+
maxLength={startWord.length}
249+
className="flex-grow p-3 bg-gray-900/50 font-mono border rounded-md focus:ring-0 text-app-light border-app-alpha-50 text-center"
250+
placeholder={`Enter a ${startWord.length}-letter word`}
251+
disabled={gameControlsDisabled}
252+
/>
253+
<button
254+
type="submit"
255+
className="px-4 py-2 rounded-md font-semibold bg-tb text-app border-app-alpha-50 hover:bg-app/15 border flex items-center gap-2 justify-center"
256+
disabled={gameControlsDisabled}
257+
>
258+
<LadderSimpleIcon size={24} /> Submit Guess
259+
</button>
260+
</form>
261+
)}
262+
263+
{message && (
264+
<div
265+
className={`p-4 rounded-md text-center font-semibold text-lg my-4
266+
${gameStatus === 'won' ? 'bg-green-700 text-green-100' : 'bg-red-700 text-red-100'}
267+
`}
268+
>
269+
{message}
270+
</div>
271+
)}
272+
273+
<div className="flex justify-center mt-4">
274+
<button
275+
onClick={handleResetGame}
276+
className="px-4 py-2 rounded-md font-semibold bg-tb text-app border-app-alpha-50 hover:bg-app/15 border flex items-center gap-2"
277+
>
278+
<ArrowCounterClockwiseIcon size={24} /> New Game
279+
</button>
280+
</div>
281+
282+
<div className="bg-app-alpha-10 border border-app-alpha-50 text-app p-4 mt-8 rounded-md">
283+
<div className="flex items-center mb-2">
284+
<LightbulbIcon size={24} className="mr-3" />
285+
<p className="font-bold">How to Play:</p>
286+
</div>
287+
<ul className="list-disc list-inside ml-5 text-sm">
288+
<li>Start with the given word.</li>
289+
<li>Change one letter at a time to form a new valid word.</li>
290+
<li>Continue until you reach the target word.</li>
291+
<li>Each intermediate word must be a valid English word.</li>
292+
</ul>
293+
</div>
294+
</div>
295+
</div>
296+
</div>
297+
</div>
298+
</div>
299+
);
300+
};
301+
302+
export default WordLadderPage;

src/utils/appIcons.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
TimerIcon,
3434
TranslateIcon,
3535
PuzzlePieceIcon,
36+
LadderSimpleIcon,
3637
} from '@phosphor-icons/react';
3738

3839
export const appIcons = {
@@ -70,4 +71,5 @@ export const appIcons = {
7071
TimerIcon,
7172
TranslateIcon,
7273
PuzzlePieceIcon,
74+
LadderSimpleIcon,
7375
};

0 commit comments

Comments
 (0)