This project is an original word game inspired by popular word-guessing mechanics. It is not affiliated with or endorsed by The New York Times.
React + TypeScript Wordle clone with:
- Difficulty modes and scoring/streak system
- Profile and scoreboard routes
- Convex-backed online scoreboard and dictionary
- Local offline fallbacks and resume flow
- PWA support (installable app)
- Node.js
22.12.0(see.nvmrc) - npm
- Install dependencies:
npm install- (Optional) Start Convex backend:
npm run convex:dev- Create
.env.local(or copy.env.example) and set:
VITE_CONVEX_URL=https://YOUR-DEPLOYMENT.convex.cloud
DAILY_PROXY_TARGET=http://localhost:8787
VITE_DAILY_WORD_API_URL=/api/daily
VITE_WORD_LIST_BUTTON_ENABLED=true
VITE_SETTINGS_DRAWER_ENABLED=true- (Recommended for Daily mode in local dev) start the sibling daily backend:
cd ../wordle-daily-backend
npm install
npm run dev- Run the app:
npm run devIf VITE_CONVEX_URL is not set, the app still works with local-only behavior for scoreboard and dictionary cache.
- Base points are the remaining attempts after a win.
- Difficulty multiplies base points:
easy x1,normal x2,hard x5,insane x7. insanealso adds+1point for every4seconds left.normaladds a+0.4dictionary bonus per valid non-answer guess row.- Streak acts as a multiplier (capped at
100) instead of a flat addition:
const scoreBase = basePoints * difficultyMultiplier + timeBonus;
const scoreFinal = Math.round(scoreBase * (1 + 0.3 * Math.sqrt(streak)));This list centralizes the main game-related constants (difficulty, timer, board, dictionary, and persistence keys).
| Constant | Value | Source |
|---|---|---|
WORD_LENGTH |
5 |
src/domain/wordle/constants.ts |
MAX_GUESSES |
6 |
src/domain/wordle/constants.ts |
BOARD_ROWS |
6 |
src/domain/wordle/board/constants.ts |
BOARD_COLUMNS |
5 |
src/domain/wordle/board/constants.ts |
DIFFICULTY_SCORE_MULTIPLIERS |
{ easy: 1, normal: 2, hard: 5, insane: 7 } |
src/domain/wordle/constants.ts |
STREAK_MODIFIER |
0.3 |
src/domain/wordle/constants.ts |
MAX_STREAK_FOR_SCORE_MULTIPLIER |
100 |
src/domain/wordle/constants.ts |
LIGHTNING_SECONDS_BONUS |
4 |
src/domain/wordle/constants.ts |
NORMAL_DICTIONARY_ROW_BONUS |
0.4 |
src/domain/wordle/constants.ts |
SCORE_DECIMAL_FACTOR |
10 |
src/domain/wordle/constants.ts |
MIN_ROUND_DURATION_FOR_SCORE_COMMIT_MS |
4000 |
src/domain/wordle/constants.ts |
PlayerDifficulty |
"easy" | "normal" | "hard" | "insane" |
src/domain/wordle/player.ts |
| Constant | Value | Source |
|---|---|---|
HARD_MODE_TOTAL_SECONDS |
60 |
src/views/Play/hooks/usePlayController/constants.ts |
HARD_MODE_FINAL_STRETCH_SECONDS |
15 |
src/views/Play/hooks/usePlayController/constants.ts |
HARD_MODE_CLOCK_BOOST_SCALES |
[0.28, 0.2, 0.14, 0.1] |
src/views/Play/hooks/usePlayController/constants.ts |
HARD_MODE_CLOCK_BOOST_THRESHOLDS |
[30, 45] |
src/views/Play/hooks/usePlayController/constants.ts |
EASY_MODE_HINT_LIMIT |
2 |
src/views/Play/hooks/useHintController/constants.ts |
NORMAL_MODE_HINT_LIMIT |
1 |
src/views/Play/hooks/useHintController/constants.ts |
HARD_MODE_HINT_LIMIT |
0 |
src/views/Play/hooks/useHintController/constants.ts |
| Constant | Value | Source |
|---|---|---|
DEFAULT_PLAYER_DIFFICULTY |
"normal" |
src/providers/Player/constants.ts |
DEFAULT_PLAYER_KEYBOARD_PREFERENCE |
"onscreen" |
src/providers/Player/constants.ts |
DEFAULT_PLAYER_LANGUAGE |
"en" |
src/providers/Player/constants.ts |
PlayerKeyboardPreference |
"onscreen" | "native" |
src/domain/wordle/player.ts |
PlayerLanguage |
"en" | "es" |
src/domain/wordle/player.ts |
| Constant | Value | Source |
|---|---|---|
WORDS_DEFAULT_LANGUAGE |
"es" |
src/api/words/constants.ts |
WORDS_SUPPORTED_LANGUAGES |
["es"] |
src/api/words/constants.ts |
WORDS_CACHE_KEY_PREFIX |
"wordle:dictionary" |
src/api/words/constants.ts |
WORDS_CHECKSUM_KEY_PREFIX |
"wordle:dictionary:checksum" |
src/api/words/constants.ts |
| Constant | Value | Source |
|---|---|---|
WORDLE_GAME_STORAGE_KEY |
"wordle:game" |
src/config/constants.ts |
WORDLE_SESSION_STORAGE_KEY |
"wordle:session-id" |
src/domain/wordle/constants.ts |
HINT_USAGE_STORAGE_KEY |
"wordle:hint-usage" |
src/views/Home/hooks/useHintController/constants.ts |
WORDLE_SYNC_EVENTS_KEY |
"wordle:sync-events" |
src/api/score/constants.ts |
END_OF_GAME_DIALOG_SEEN_SESSION_STORAGE_KEY |
"wordle:end-of-game-dialog-seen" |
src/views/Play/hooks/usePlayController/constants.ts |
HARD_MODE_TIMER_STORAGE_KEY |
"wordle:hard-mode-timer" |
src/views/Play/hooks/usePlayController/constants.ts |
SCOREBOARD_CACHE_KEY |
"wordle:scoreboard:cache" |
src/api/score/constants.ts |
SCOREBOARD_PENDING_KEY |
"wordle:scoreboard:pending" |
src/api/score/constants.ts |
SCOREBOARD_CLIENT_ID_KEY |
"wordle:scoreboard:client-id" |
src/api/score/constants.ts |
SCOREBOARD_PROFILE_IDENTITY_KEY |
"wordle:scoreboard:profile-identity" |
src/api/score/constants.ts |
WORDLE_ANIMATIONS_DISABLED_STORAGE_KEY |
"wordle:disable-start-animations" |
src/domain/wordle/constants.ts |
WORDLE_START_ANIMATION_SESSION_KEY |
"wordle:start-animation-session-seen" |
src/domain/wordle/constants.ts |
WORDLE_KEYBOARD_ENTRY_ANIMATION_SESSION_KEY |
"wordle:keyboard-entry-animation-seen" |
src/domain/wordle/constants.ts |
| Constant | Value | Source |
|---|---|---|
MESSAGE_VISIBILITY_DURATION_MS |
1800 |
src/hooks/useWordle/constants.ts |
GAME_STATE_PERSIST_DEBOUNCE_MS |
150 |
src/hooks/useWordle/constants.ts |
COMBO_FLASH_VISIBILITY_DURATION_MS |
820 |
src/views/Play/hooks/usePlayController/constants.ts |
CHALLENGE_COMPLETION_ALERT_VISIBILITY_DURATION_MS |
4000 |
src/views/Play/hooks/usePlayController/constants.ts |
SCORE_LIMIT |
10 |
src/config/constants.ts |
All flags are read from src/config/env.ts.
VITE_APP_VERSION(default"0.0.0"): app version.VITE_CONVEX_URL(optional): Convex deployment URL.VITE_DAILY_WORD_API_URL(default"/api/daily"): base endpoint used byDailyWordClientto fetch the daily payload (word + meaning) in a single request.VITE_WORD_REPORT_PHONE_NUMBER(optional): WhatsApp target for the invalid-word report link.VITE_PAYPAL_DONATION_BUTTON_URL(optional): PayPal donation URL.DAILY_PROXY_TARGET(default"http://localhost:8787"): dev proxy target for/api/dailyin Vite.
Feature flags (all default true unless noted):
VITE_WORD_LIST_BUTTON_ENABLED: enables the Words button (shown only in Easy).VITE_WORD_REPORT_BUTTON_ENABLED: enables the invalid-word report button.VITE_PAYPAL_DONATION_BUTTON_ENABLED: enables the donate button.VITE_SHARE_BUTTON_ENABLED: enables the victory share action.VITE_DEV_CONSOLE_ENABLED: enables the developer console (only in dev mode at runtime).VITE_SOUND_ENABLED: enables procedural sound effects.VITE_MASTER_AND_MUSIC_CHANNELS_ENABLED(defaultfalse): enables themasterandmusicchannels. Whenfalse, the app runs with SFX-only channel controls and disables music playback.VITE_HINTS_ENABLED: enables the hint system.VITE_HELP_BUTTON_ENABLED: enables the help button.VITE_CHALLENGES_ENABLED: enables daily challenges.VITE_SETTINGS_DRAWER_ENABLED: enables the in-game settings drawer.VITE_PLAY_OFFLINE_STATE_ENABLED(defaultfalse): shows the offline state in Play view.VITE_LIGHTNING_MODE_ENABLED: enables the lightning/insane timer mode.VITE_LIGHTNING_START_CUE_AND_AUTO_TIMER_ENABLED(defaultfalse): enables the lightning start cue (Begin) and starts the lightning timer immediately on screen entry. Whenfalse, cue is hidden and timer starts on first input.VITE_TIMER_AUTO_PAUSE_ENABLED(defaultfalse): pauses the hard-mode timer when Play dialogs are open or the browser tab is hidden.VITE_DIFFICULTY_EASY_ENABLED/VITE_DIFFICULTY_NORMAL_ENABLED/VITE_DIFFICULTY_HARD_ENABLED/VITE_DIFFICULTY_INSANE_ENABLED: individual difficulty toggles.
Notes:
- The Words button is only shown in Easy difficulty.
- The button is disabled while dictionary data is loading or unavailable.
npm run dev: start Vite dev server.npm run build: production build.npm run preview: preview production build locally.npm run test: run tests with Vitest.npm run coverage: run tests with coverage.npm run lint: run typecheck + eslint + prettier check + depcheck.
- User-facing copy in JSX must go through
i18next/react-i18next. - ESLint blocks new literal strings in JSX text nodes and visible attributes such as
aria-label,title,placeholder, andalt. - Technical constants, test files,
src/i18n/**, and moduleconstants.tsfiles are excluded from that rule. npm run lint-prettier: validate formatting with Prettier.npm run prettier: format the project with Prettier.npm run convex:dev: start Convex dev backend.npm run convex:deploy: deploy Convex functions/schema.npm run convex:backfill-player-codes: assign missing 4-character recovery codes to existing remote players.
sessionStoragewordle:session-id: per-tab session id.
localStoragewordle:game: in-progress game reference{ sessionId, gameId, seed, guesses, current, gameOver }.answeris not stored; it is derived locally fromgameId,seed, and the dictionary.
wordle:hint-usage: hint usage snapshot keyed by game reference, without storinganswerin clear.wordle:sync-events: pending offline victory events used to sync score/streak to Convex in order.wordle:dictionary:es: cached dictionary.wordle:daily-word:<YYYY-MM-DD>: cached remote daily word.wordle:daily-meaning:<YYYY-MM-DD>:<WORD>: cached meaning for the daily word fetched from the daily payload.player: player profile and score/streak metadata, including recoverycode.- local
score/streakact as UI cache; confirmed remote values take precedence after sync.
- local
wordle:scoreboard:*: scoreboard cache/pending/client metadata.wordle:scoreboard:profile-identity: adopted remote profile identity (clientRecordId) after profile creation or recovery.
Resume rules:
- Same tab + refresh restores the current board automatically.
- New tab + in-progress board from another tab session prompts to continue or start fresh.
- A named profile gets a unique 4-character recovery code (
A-Z,0-9) from Convex. - The code is shown in
Profileand is read-only. - The initial dialog now supports two explicit identity actions:
- create a unique player name
- recover an existing profile with its code
- Identity operations are remote-first. If Convex is unavailable, profile creation/recovery fails without mutating the local player into an unrecognized state.
Backfill existing remote players:
npm run convex:backfill-player-codesYou can pass extra Convex CLI flags through the script, for example:
npm run convex:backfill-player-codes -- --prod- Dictionary words are stored in Convex (
wordstable) by language. - Gameplay dictionary language is fixed to
es. - The app reads cached words first and only fetches from Convex when cache is empty.
- The fetched dictionary is cached locally in
wordle:dictionary:es.
- Powered by
vite-plugin-pwa. - Production build generates
manifest.webmanifestand service worker assets. - Icons live in
public/(pwa-192x192.png,pwa-512x512.png, and maskable variants).
Local installability check:
npm run build
npm run previewOpen the preview URL in Chrome/Edge and use the install button in the address bar.
For architecture and layering rules, see:
ARCHITECTURE.mdAGENTS.md