let targetWords = [] let dictionary = [] let dictionarySet = new Set() let targetWord = "" let gameFinished = false let currentGuessIndex = 0 let currentTileIndex = 0 let lastResult = null let hasInitialized = false let wordListMode = "loading" let isAnimating = false let hourlyRound = null let lockCountdownTimer = null let supabaseClient = null const WORD_LENGTH = 5 const MAX_GUESSES = 6 const FLIP_ANIMATION_DURATION = 500 const DANCE_ANIMATION_DURATION = 500 const STATS_KEY = "fancy-wordle-stats-v2" const LOCAL_ROUND_KEY = "fancy-wordle-hourly-round-v1" const PLAY_INTERVAL_MS = 60 * 60 * 1000 const KEY_ROWS = ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"] const FALLBACK_TARGET_WORDS = [ "about", "after", "apple", "beach", "brain", "chair", "charm", "dream", "field", "flame", "grape", "heart", "house", "light", "magic", "music", "plant", "river", "smile", "stone", "table", "world" ] const FALLBACK_EXTRA_DICTIONARY = [ "adieu", "audio", "crane", "later", "least", "raise", "roast", "slate", "stare", "tears", "trace" ] const keyboard = document.querySelector("[data-keyboard]") const alertContainer = document.querySelector("[data-alert-container]") const guessGrid = document.querySelector("[data-guess-grid]") const roundStatus = document.getElementById("round-status") const triesStatus = document.getElementById("tries-status") const themeButton = document.getElementById("theme-button") const statsButton = document.getElementById("Stats-button") const statsModal = document.getElementById("stats-modal") const statsGrid = document.getElementById("stats-grid") const guessBars = document.getElementById("guess-bars") const resetStatsButton = document.getElementById("reset-stats") const shareResultsButton = document.getElementById("share-results") if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initializeGame, { once: true }) } else { initializeGame() } async function initializeGame() { if (hasInitialized) return hasInitialized = true createBoard() createKeyboard() restoreTheme() bindControls() try { await loadWordLists() roundStatus.textContent = wordListMode === "full" ? "Ready" : "Answer list loaded" } catch (error) { console.error("Failed to initialize game:", error) useFallbackWordLists() roundStatus.textContent = "Offline word set" showAlert("Using a compact offline word set", 3000) } await startHourlyRound() } async function loadWordLists() { targetWords = await loadWordList("targetWords") if (targetWords.length === 0) { throw new Error("targetWords.json is empty or invalid") } let dictionaryWords = [] try { dictionaryWords = await loadWordList("dictionary") } catch (error) { console.warn("dictionary.json unavailable; accepting target words only.", error) } dictionary = normalizeWords([ ...dictionaryWords, ...targetWords ]) dictionarySet = new Set(dictionary) wordListMode = dictionaryWords.length > 0 ? "full" : "answers-only" if (dictionarySet.size === 0) { throw new Error("Accepted word list is empty or invalid") } } async function loadWordList(listName) { const fileName = listName === "targetWords" ? "targetWords.json" : "dictionary.json" try { return normalizeWords(await fetchJsonFile(fileName)) } catch (error) { const embeddedWords = getEmbeddedWordList(listName) if (embeddedWords.length > 0) { console.info(`${fileName} unavailable; using bundled word data.`, error) return embeddedWords } throw error } } function getEmbeddedWordList(listName) { return normalizeWords(window.FANCY_WORDLE_WORD_DATA?.[listName]) } function useFallbackWordLists() { targetWords = [...FALLBACK_TARGET_WORDS] dictionary = [...new Set([...FALLBACK_TARGET_WORDS, ...FALLBACK_EXTRA_DICTIONARY])] dictionarySet = new Set(dictionary) wordListMode = "fallback" } async function fetchJsonFile(fileName) { const response = await fetch(fileName, { cache: "no-store" }) if (!response.ok) { throw new Error(`${fileName} returned ${response.status}`) } return response.json() } function normalizeWords(words) { if (!Array.isArray(words)) return [] return words .map(word => String(word).trim().toLowerCase()) .filter(word => word.length === WORD_LENGTH) } async function startHourlyRound() { const selectedWord = getCurrentHourlyWord() const remoteRound = await startRemoteHourlyRound() const round = remoteRound || startLocalHourlyRound(selectedWord) applyHourlyRound(round) } async function startRemoteHourlyRound() { const client = getSupabaseClient() if (!client) return null try { await ensureSupabaseSession(client) const { data, error } = await client.rpc("start_hourly_round") if (error) throw error const row = Array.isArray(data) ? data[0] : data if (!row) throw new Error("Supabase did not return a round") return normalizeRemoteRound(row) } catch (error) { console.warn("Supabase hourly lock unavailable; using local lock.", error) showAlert("Using local hourly lock", 3000) return null } } function getSupabaseClient() { const config = window.FANCY_WORDLE_SUPABASE || {} if (!config.url || !config.anonKey || !window.supabase) return null if (!supabaseClient) { supabaseClient = window.supabase.createClient(config.url, config.anonKey) } return supabaseClient } async function ensureSupabaseSession(client) { const { data: { session } } = await client.auth.getSession() if (session) return const { error } = await client.auth.signInAnonymously() if (error) throw error } function normalizeRemoteRound(row) { return { id: row.round_id, backend: "supabase", word: row.word, hourStart: Date.parse(row.hour_start), startedAt: Date.parse(row.started_at), nextPlayableAt: Date.parse(row.next_playable_at), completedAt: row.completed_at ? Date.parse(row.completed_at) : null, won: row.won, guessCount: row.guess_count, isExisting: row.is_existing } } function startLocalHourlyRound(selectedWord) { const savedRound = getLocalHourlyRound() if (savedRound && savedRound.nextPlayableAt > Date.now()) return savedRound const now = Date.now() const hourStart = getCurrentHourStart(now) const round = { id: `local-${now}`, backend: "local", word: selectedWord, hourStart, startedAt: now, nextPlayableAt: hourStart + PLAY_INTERVAL_MS, completedAt: null, won: null, guessCount: null, isExisting: false } saveLocalHourlyRound(round) return round } function getLocalHourlyRound() { try { const round = JSON.parse(localStorage.getItem(LOCAL_ROUND_KEY)) if (!round?.word || !round?.nextPlayableAt) return null return { ...round, backend: "local", hourStart: Number(round.hourStart) || getCurrentHourStart(Number(round.startedAt) || Date.now()), startedAt: Number(round.startedAt), nextPlayableAt: Number(round.nextPlayableAt), completedAt: round.completedAt ? Number(round.completedAt) : null, isExisting: true } } catch { return null } } function saveLocalHourlyRound(round) { localStorage.setItem(LOCAL_ROUND_KEY, JSON.stringify(round)) } function getCurrentHourlyWord() { const hourStart = getCurrentHourStart(Date.now()) const hourIndex = Math.floor(hourStart / PLAY_INTERVAL_MS) return targetWords[hourIndex % targetWords.length] } function getCurrentHourStart(timestamp) { return Math.floor(timestamp / PLAY_INTERVAL_MS) * PLAY_INTERVAL_MS } function applyHourlyRound(round) { hourlyRound = round targetWord = round.word dictionarySet.add(targetWord) currentGuessIndex = round.completedAt ? round.guessCount || 0 : 0 updateTriesStatus() if (round.completedAt) { lastResult = { won: Boolean(round.won), guesses: round.guessCount || MAX_GUESSES, word: round.word } lockUntilNextWord() return } roundStatus.textContent = round.isExisting ? "Hourly word resumed" : "Hourly word ready" startInteraction() } function createBoard() { guessGrid.innerHTML = "" for (let index = 0; index < WORD_LENGTH * MAX_GUESSES; index += 1) { const tile = document.createElement("div") tile.className = "tile" tile.setAttribute("role", "img") tile.setAttribute("aria-label", "Empty letter") guessGrid.append(tile) } } function createKeyboard() { keyboard.innerHTML = "" KEY_ROWS.forEach((row, rowIndex) => { if (rowIndex === 1) keyboard.append(createSpacer()) if (rowIndex === 2) { keyboard.append(createActionKey("Enter", "Enter", "data-enter")) } row.split("").forEach(letter => { const key = document.createElement("button") key.className = "key" key.type = "button" key.dataset.key = letter key.textContent = letter key.setAttribute("aria-label", letter) keyboard.append(key) }) if (rowIndex === 2) { keyboard.append(createActionKey("⌫", "Delete letter", "data-delete")) } }) } function createSpacer() { const spacer = document.createElement("span") spacer.className = "key space" spacer.setAttribute("aria-hidden", "true") return spacer } function createActionKey(label, ariaLabel, attribute) { const key = document.createElement("button") key.className = "key large" key.type = "button" key.textContent = label key.setAttribute(attribute, "") key.setAttribute("aria-label", ariaLabel) return key } function bindControls() { themeButton.addEventListener("click", toggleTheme) statsButton.addEventListener("click", openStats) resetStatsButton.addEventListener("click", resetStats) shareResultsButton.addEventListener("click", shareResult) document.querySelector(".brand").addEventListener("click", event => { event.preventDefault() window.location.reload() }) } function startInteraction() { stopInteraction() document.addEventListener("click", handleMouseClick) document.addEventListener("keydown", handleKeyPress) } function stopInteraction() { document.removeEventListener("click", handleMouseClick) document.removeEventListener("keydown", handleKeyPress) } function handleMouseClick(event) { const keyButton = event.target.closest("[data-key]") if (keyButton) { pressKey(keyButton.dataset.key) return } if (event.target.closest("[data-enter]")) { submitGuess() return } if (event.target.closest("[data-delete]")) { deleteKey() } } function handleKeyPress(event) { if (statsModal.open) return if (event.key === "Enter") { submitGuess() return } if (event.key === "Backspace" || event.key === "Delete") { deleteKey() return } if (/^[a-z]$/i.test(event.key)) { pressKey(event.key) } } function pressKey(key) { if (gameFinished || isAnimating) return if (currentTileIndex >= WORD_LENGTH) return const nextTile = getCurrentRowTiles()[currentTileIndex] if (!nextTile) return const letter = key.toLowerCase() nextTile.dataset.letter = letter nextTile.textContent = letter nextTile.dataset.state = "active" nextTile.setAttribute("aria-label", letter) currentTileIndex += 1 } function deleteKey() { if (gameFinished || isAnimating) return if (currentTileIndex === 0) return currentTileIndex -= 1 const lastTile = getCurrentRowTiles()[currentTileIndex] if (!lastTile) return lastTile.textContent = "" lastTile.removeAttribute("data-state") lastTile.removeAttribute("data-letter") lastTile.setAttribute("aria-label", "Empty letter") } function submitGuess() { if (gameFinished || isAnimating) return const activeTiles = getCurrentRowTiles().filter(tile => tile.dataset.letter) if (activeTiles.length !== WORD_LENGTH) { showAlert("Not enough letters") shakeTiles(activeTiles) return } const guess = activeTiles.map(tile => tile.dataset.letter).join("") if (!isValidGuess(guess)) { showAlert("Not in word list") shakeTiles(activeTiles) return } stopInteraction() isAnimating = true roundStatus.textContent = "Checking guess…" currentGuessIndex += 1 updateTriesStatus() const states = scoreGuess(guess, targetWord) activeTiles.forEach((tile, index, tiles) => { flipTile(tile, index, tiles, guess, states[index]) }) } function isValidGuess(guess) { if (!/^[a-z]{5}$/.test(guess)) return false return dictionarySet.has(guess) } function scoreGuess(guess, answer) { const states = Array(WORD_LENGTH).fill("wrong") const remaining = {} for (let index = 0; index < WORD_LENGTH; index += 1) { if (guess[index] === answer[index]) { states[index] = "correct" } else { remaining[answer[index]] = (remaining[answer[index]] || 0) + 1 } } for (let index = 0; index < WORD_LENGTH; index += 1) { const letter = guess[index] if (states[index] === "correct") continue if (remaining[letter] > 0) { states[index] = "wrong-position" remaining[letter] -= 1 } } return states } function flipTile(tile, index, tiles, guess, state) { const letter = tile.dataset.letter const key = keyboard.querySelector(`[data-key="${letter}"i]`) setTimeout(() => { tile.classList.add("flip") setTimeout(() => { tile.classList.remove("flip") tile.textContent = letter tile.dataset.state = state tile.setAttribute("aria-label", `${letter}, ${readableState(state)}`) updateKeyState(key, state) if (index === tiles.length - 1) { setTimeout(() => { currentTileIndex = 0 isAnimating = false checkWinLose(guess, tiles) }, FLIP_ANIMATION_DURATION / 2) } }, FLIP_ANIMATION_DURATION / 2) }, (index * FLIP_ANIMATION_DURATION) / 2) } function updateKeyState(key, state) { if (!key) return const rank = { wrong: 1, "wrong-position": 2, correct: 3 } const currentState = ["wrong", "wrong-position", "correct"].find(name => key.classList.contains(name)) if (!currentState || rank[state] > rank[currentState]) { key.classList.remove("wrong", "wrong-position", "correct") key.classList.add(state) } } function readableState(state) { if (state === "correct") return "correct" if (state === "wrong-position") return "in the word, wrong spot" return "not in the word" } function getActiveTiles() { return guessGrid.querySelectorAll('[data-state="active"]') } function getCurrentRowTiles() { const tiles = [...guessGrid.children] const rowStart = currentGuessIndex * WORD_LENGTH return tiles.slice(rowStart, rowStart + WORD_LENGTH) } function showAlert(message, duration = 1400) { const alert = document.createElement("div") alert.textContent = message alert.className = "alert" alertContainer.prepend(alert) if (duration == null) return setTimeout(() => dismissAlert(alert), duration) } function dismissAlert(alert) { alert.classList.add("hide") alert.addEventListener("transitionend", () => alert.remove(), { once: true }) } function shakeTiles(tiles) { tiles.forEach(tile => { tile.classList.add("shake") tile.addEventListener("animationend", () => tile.classList.remove("shake"), { once: true }) }) } function checkWinLose(guess, tiles) { if (guess === targetWord) { gameFinished = true lastResult = { won: true, guesses: currentGuessIndex, word: targetWord } saveGameResult(lastResult) playCelebrationSound() showAlert("Congratulations, you win!", 5000) danceTiles(tiles) celebrateWithConfetti() roundStatus.textContent = `Solved in ${currentGuessIndex}` setTimeout(() => showWordDefinition(targetWord, true), 1800) startLockCountdown(2400) stopInteraction() return } const remainingTiles = guessGrid.querySelectorAll(":not([data-letter])") if (remainingTiles.length === 0) { gameFinished = true lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord } saveGameResult(lastResult) showAlert(targetWord.toUpperCase(), null) roundStatus.textContent = "Round complete" showWordDefinition(targetWord, false) startLockCountdown(2400) stopInteraction() return } roundStatus.textContent = "Keep going" startInteraction() } function updateTriesStatus() { triesStatus.textContent = `${currentGuessIndex} / ${MAX_GUESSES}` } function lockUntilNextWord() { gameFinished = true stopInteraction() startLockCountdown() } function startLockCountdown(delay = 0) { clearLockCountdown() const beginCountdown = () => { updateLockCountdown() lockCountdownTimer = setInterval(updateLockCountdown, 1000) } if (delay > 0) { lockCountdownTimer = setTimeout(beginCountdown, delay) return } beginCountdown() } function clearLockCountdown() { if (!lockCountdownTimer) return clearTimeout(lockCountdownTimer) clearInterval(lockCountdownTimer) lockCountdownTimer = null } function updateLockCountdown() { if (!hourlyRound?.nextPlayableAt) return const remaining = hourlyRound.nextPlayableAt - Date.now() if (remaining <= 0) { clearLockCountdown() if (hourlyRound.backend === "local") localStorage.removeItem(LOCAL_ROUND_KEY) roundStatus.textContent = "New word available" triesStatus.textContent = "Ready" showAlert("New word available. Refresh to play.", null) return } roundStatus.textContent = `Next word in ${formatRemainingTime(remaining)}` triesStatus.textContent = "Locked" } function formatRemainingTime(milliseconds) { const totalSeconds = Math.max(1, Math.ceil(milliseconds / 1000)) const minutes = Math.floor(totalSeconds / 60) const seconds = totalSeconds % 60 if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s` return `${seconds}s` } function playCelebrationSound() { const audio = new Audio("Celebration.mp3") audio.volume = 0.45 audio.play().catch(error => { console.info("Celebration sound was blocked or unavailable:", error) }) } async function showWordDefinition(word, isWin = true) { try { const response = await fetch(`https://api.dictionaryapi.dev/api/v2/entries/en/${word}`) if (!response.ok) throw new Error(`API request failed: ${response.status}`) const data = await response.json() const firstMeaning = data[0]?.meanings?.[0] const definition = firstMeaning?.definitions?.[0]?.definition const partOfSpeech = firstMeaning?.partOfSpeech const example = firstMeaning?.definitions?.[0]?.example if (!definition) throw new Error("No definition found") showDefinitionAlert({ title: `${word.toUpperCase()} ${partOfSpeech ? `(${partOfSpeech})` : ""}`, body: definition, example, isWin }) } catch (error) { console.info("Definition lookup unavailable:", error) showDefinitionAlert({ title: word.toUpperCase(), body: "Definition not available at the moment.", isWin }) } } function showDefinitionAlert({ title, body, example, isWin }) { const alert = document.createElement("button") alert.type = "button" alert.className = `alert definition-alert ${isWin ? "win-definition" : "lose-definition"}` alert.innerHTML = ` ${escapeHtml(title)} ${escapeHtml(body)} ${example ? `

Example: “${escapeHtml(example)}”
` : ""} ` alertContainer.prepend(alert) alert.addEventListener("click", () => dismissAlert(alert)) setTimeout(() => dismissAlert(alert), 10000) } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'") } function celebrateWithConfetti() { if (typeof confetti === "undefined") return const colors = ["#c87949", "#e8c66f", "#7e9f70", "#f7efe6", "#332820"] confetti({ particleCount: 90, spread: 70, origin: { y: 0.58 }, colors }) setTimeout(() => { confetti({ particleCount: 46, angle: 60, spread: 55, origin: { x: 0 }, colors }) confetti({ particleCount: 46, angle: 120, spread: 55, origin: { x: 1 }, colors }) }, 220) } function danceTiles(tiles) { tiles.forEach((tile, index) => { setTimeout(() => { tile.classList.add("dance") tile.addEventListener("animationend", () => tile.classList.remove("dance"), { once: true }) }, (index * DANCE_ANIMATION_DURATION) / WORD_LENGTH) }) } function restoreTheme() { const selectedTheme = localStorage.getItem("selected-theme") if (selectedTheme === "dark") { document.body.classList.add("dark-theme") themeButton.setAttribute("aria-label", "Switch to light mode") } } function toggleTheme() { document.body.classList.toggle("dark-theme") const theme = document.body.classList.contains("dark-theme") ? "dark" : "light" localStorage.setItem("selected-theme", theme) themeButton.setAttribute("aria-label", theme === "dark" ? "Switch to light mode" : "Switch to dark mode") } function openStats() { renderStats() statsModal.showModal() } function getStats() { const fallback = { played: 0, wins: 0, currentStreak: 0, maxStreak: 0, distribution: [0, 0, 0, 0, 0, 0] } try { return { ...fallback, ...JSON.parse(localStorage.getItem(STATS_KEY)) } } catch { return fallback } } function saveStats(stats) { localStorage.setItem(STATS_KEY, JSON.stringify(stats)) } function saveGameResult(result) { const stats = getStats() stats.played += 1 if (result.won) { stats.wins += 1 stats.currentStreak += 1 stats.maxStreak = Math.max(stats.maxStreak, stats.currentStreak) stats.distribution[result.guesses - 1] += 1 } else { stats.currentStreak = 0 } saveStats(stats) completeHourlyRound(result) } function completeHourlyRound(result) { if (!hourlyRound || hourlyRound.completedAt) return hourlyRound = { ...hourlyRound, completedAt: Date.now(), won: result.won, guessCount: result.guesses } if (hourlyRound.backend === "local") { saveLocalHourlyRound(hourlyRound) return } completeRemoteHourlyRound(result).catch(error => { console.warn("Failed to complete Supabase hourly round:", error) }) } async function completeRemoteHourlyRound(result) { const client = getSupabaseClient() if (!client || !hourlyRound?.id) return const { error } = await client.rpc("complete_hourly_round", { round_id: hourlyRound.id, did_win: result.won, guess_total: result.guesses }) if (error) throw error } function renderStats() { const stats = getStats() const winRate = stats.played === 0 ? 0 : Math.round((stats.wins / stats.played) * 100) const statItems = [ ["Played", stats.played], ["Win %", winRate], ["Streak", stats.currentStreak], ["Best", stats.maxStreak] ] statsGrid.innerHTML = statItems .map(([label, value]) => `
${value}${label}
`) .join("") const maxGuessCount = Math.max(1, ...stats.distribution) guessBars.innerHTML = stats.distribution .map((count, index) => { const width = Math.max(8, Math.round((count / maxGuessCount) * 100)) return `
${index + 1} ${count}
` }) .join("") } function resetStats() { localStorage.removeItem(STATS_KEY) renderStats() showAlert("Stats reset") } async function shareResult() { const text = buildShareText() try { if (navigator.share) { await navigator.share({ text }) } else if (navigator.clipboard) { await navigator.clipboard.writeText(text) showAlert("Result copied") } else { showAlert(text, 5000) } } catch (error) { console.info("Share cancelled or unavailable:", error) } } function buildShareText() { const status = lastResult ? `${lastResult.won ? lastResult.guesses : "X"}/${MAX_GUESSES}` : `${currentGuessIndex}/${MAX_GUESSES}` const roundLabel = getShareRoundLabel() return `Fancy Wordle ${roundLabel} ${status}\n${getResultGrid()}` } function getShareRoundLabel() { if (!Number.isFinite(hourlyRound?.hourStart)) return "Hourly" return new Date(hourlyRound.hourStart).toISOString().slice(0, 13).replace("T", " ") + ":00 UTC" } function getResultGrid() { const submittedTiles = [...guessGrid.querySelectorAll("[data-letter]")] .filter(tile => tile.dataset.state && tile.dataset.state !== "active") if (submittedTiles.length === 0) return "No guesses yet." return chunk(submittedTiles, WORD_LENGTH) .map(row => row.map(tile => { if (tile.dataset.state === "correct") return "🟩" if (tile.dataset.state === "wrong-position") return "🟨" return "⬛" }).join("")) .join("\n") } function chunk(items, size) { const rows = [] for (let index = 0; index < items.length; index += size) { rows.push(items.slice(index, index + size)) } return rows }