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 let authSession = null let authProfile = null let leaderboardScope = "hour" let latestLeaderboardRows = [] let lastDefinition = 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 GUESS_TIMEOUT_MS = 10000 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 leaderboardButton = document.getElementById("leaderboard-button") const leaderboardModal = document.getElementById("leaderboard-modal") const leaderboardList = document.getElementById("leaderboard-list") const leaderboardResult = document.getElementById("leaderboard-result") const leaderboardCountdown = document.getElementById("leaderboard-countdown") const leaderboardTabs = document.querySelectorAll("[data-leaderboard-scope]") const authButton = document.getElementById("auth-button") const authModal = document.getElementById("auth-modal") const authForm = document.getElementById("auth-form") const accountSummary = document.getElementById("account-summary") const authCreateFields = document.getElementById("auth-create-fields") const authUsernameInput = document.getElementById("auth-username") const authEmailInput = document.getElementById("auth-email") const authPasswordInput = document.getElementById("auth-password") const authNewPasswordInput = document.getElementById("auth-new-password") const authSignUpButton = document.getElementById("auth-sign-up") const authSignInButton = document.getElementById("auth-sign-in") const authForgotPasswordButton = document.getElementById("auth-forgot-password") const authUpdatePasswordButton = document.getElementById("auth-update-password") const authResetFields = document.getElementById("auth-reset-fields") const authGuestButton = document.getElementById("auth-guest") const authSignOutButton = document.getElementById("auth-sign-out") const authStatus = document.getElementById("auth-status") const statsModal = document.getElementById("stats-modal") const statsGrid = document.getElementById("stats-grid") const statsNote = document.getElementById("stats-note") const guessBars = document.getElementById("guess-bars") const statsDefinition = document.getElementById("stats-definition") 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() await initializeAuth() 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 initializeAuth() { const client = getSupabaseClient() if (!client) { updateAuthControls() return } try { const { data: { session } } = await client.auth.getSession() authSession = session authProfile = session ? await getAuthProfile() : null updateAuthControls() client.auth.onAuthStateChange((event, session) => { authSession = session authProfile = null updateAuthControls() if (event === "PASSWORD_RECOVERY") { openAuth() setPasswordResetMode(true) return } if (session) { getAuthProfile().then(profile => { authProfile = profile updateAuthControls() }) } }) } catch (error) { console.warn("Supabase auth unavailable:", error) updateAuthControls() } } async function getAuthProfile() { const client = getSupabaseClient() if (!client || !authSession) return null const { data, error } = await client .from("profiles") .select("username, display_name") .eq("id", authSession.user.id) .maybeSingle() if (error) { console.warn("Failed to load profile:", error) return null } return data } function updateAuthControls() { const hasSupabase = Boolean(getSupabaseClient()) const label = getAuthLabel() if (authButton) { authButton.textContent = label authButton.title = authSession ? "Account" : "Sign in" authButton.setAttribute("aria-label", authSession ? "Open account" : "Sign in to sync stats") } if (authStatus) { authStatus.textContent = hasSupabase ? authSession ? `Signed in as ${label}` : "Sign in with your email and password. If we cannot find your account, you can create one here." : "Supabase is not configured. Guest play is active." } if (accountSummary) { accountSummary.hidden = !authSession accountSummary.innerHTML = authSession ? `${escapeHtml(label)}${escapeHtml(authSession.user.email || "Signed in")}` : "" } if (authForm) authForm.hidden = !hasSupabase || Boolean(authSession) if (authGuestButton) authGuestButton.hidden = Boolean(authSession) if (authSignOutButton) authSignOutButton.hidden = !authSession } function getAuthLabel() { if (!authSession) return "Sign in" return authProfile?.username || authProfile?.display_name || authSession.user.email || "Account" } 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 || !authSession) return null try { 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 } function normalizeRemoteRound(row) { return { id: row.round_id, backend: "supabase", word: row.revealed_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, guesses: normalizeRemoteGuesses(row.guesses) } } function normalizeRemoteGuesses(guesses) { if (!Array.isArray(guesses)) return [] return guesses.map(guess => ({ guess: guess.guess, rowIndex: guess.rowIndex, states: guess.states })) } 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 || "" if (targetWord) dictionarySet.add(targetWord) resetBoardForRound() restoreGuesses(round.guesses || []) currentGuessIndex = round.completedAt ? round.guessCount || currentGuessIndex : currentGuessIndex updateTriesStatus() if (round.completedAt) { lastResult = { won: Boolean(round.won), guesses: round.guessCount || MAX_GUESSES, word: round.word || "" } if (round.word) loadWordDefinition(round.word, Boolean(round.won)) lockUntilNextWord() return } roundStatus.textContent = round.isExisting ? "Hourly word resumed" : "Hourly word ready" startInteraction() } function resetBoardForRound() { [...guessGrid.children].forEach(tile => { tile.textContent = "" tile.removeAttribute("data-state") tile.removeAttribute("data-letter") tile.setAttribute("aria-label", "Empty letter") }) keyboard.querySelectorAll(".wrong, .wrong-position, .correct").forEach(key => { key.classList.remove("wrong", "wrong-position", "correct") }) currentGuessIndex = 0 currentTileIndex = 0 } function restoreGuesses(guesses) { if (!Array.isArray(guesses) || guesses.length === 0) { currentGuessIndex = 0 return } guesses.forEach(savedGuess => { const rowIndex = Number(savedGuess.rowIndex) || 0 const rowStart = (rowIndex - 1) * WORD_LENGTH const states = Array.isArray(savedGuess.states) ? savedGuess.states : [] String(savedGuess.guess || "").split("").forEach((letter, index) => { const tile = guessGrid.children[rowStart + index] if (!tile) return const state = states[index] || "wrong" tile.dataset.letter = letter tile.textContent = letter tile.dataset.state = state tile.setAttribute("aria-label", `${letter}, ${readableState(state)}`) updateKeyState(keyboard.querySelector(`[data-key="${letter}"i]`), state) }) }) currentGuessIndex = Math.min(guesses.length, MAX_GUESSES) } 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) leaderboardButton?.addEventListener("click", openLeaderboard) leaderboardTabs.forEach(tab => { tab.addEventListener("click", () => switchLeaderboardScope(tab.dataset.leaderboardScope)) }) authButton?.addEventListener("click", openAuth) authSignUpButton?.addEventListener("click", signUpWithPassword) authSignInButton?.addEventListener("click", signInWithPassword) authForgotPasswordButton?.addEventListener("click", sendPasswordReset) authUpdatePasswordButton?.addEventListener("click", updatePassword) authForm?.addEventListener("keydown", event => { if (event.key === "Enter") signInWithPassword(event) }) authGuestButton?.addEventListener("click", () => authModal.close()) authSignOutButton?.addEventListener("click", signOut) resetStatsButton.addEventListener("click", resetStats) shareResultsButton.addEventListener("click", shareResult) document.querySelector(".brand").addEventListener("click", event => { event.preventDefault() window.location.reload() }) } function openAuth() { updateAuthControls() if (!authSession) { setCreateAccountMode(false) setPasswordResetMode(false) } authModal?.showModal() } async function signUpWithPassword(event) { event.preventDefault() const client = getSupabaseClient() const email = authEmailInput?.value.trim() const password = authPasswordInput?.value const username = normalizeUsername(authUsernameInput?.value) if (!client || !email || !password || !username) { authStatus.textContent = "Username, email, and password are required." return } if (password.length < 6) { authStatus.textContent = "Password must be at least 6 characters." return } const isAvailable = await isUsernameAvailable(username) if (!isAvailable) { authStatus.textContent = "That username is already taken. Try another one." return } authStatus.textContent = "Creating account..." const { error } = await client.auth.signUp({ email, password, options: { data: { username, display_name: username } } }) authStatus.textContent = error ? readableAuthError(error) : "Account created. Check your email if confirmation is required." } async function signInWithPassword(event) { event.preventDefault() const client = getSupabaseClient() const email = authEmailInput?.value.trim() const password = authPasswordInput?.value if (!client || !email || !password) { authStatus.textContent = "Email and password are required." return } authStatus.textContent = "Signing in..." const { error } = await client.auth.signInWithPassword({ email, password }) if (error) { setCreateAccountMode(true) authStatus.textContent = `${readableAuthError(error)} Create an account below if this is your first time.` return } window.location.reload() } async function isUsernameAvailable(username) { const client = getSupabaseClient() if (!client) return false const { data, error } = await client.rpc("is_username_available", { candidate_username: username }) if (error) { console.warn("Failed to check username:", error) return false } return Boolean(data) } async function sendPasswordReset() { const client = getSupabaseClient() const email = authEmailInput?.value.trim() if (!client || !email) { authStatus.textContent = "Enter your email first, then request a reset link." return } authStatus.textContent = "Sending password reset..." const { error } = await client.auth.resetPasswordForEmail(email, { redirectTo: window.location.href }) authStatus.textContent = error ? readableAuthError(error) : "Password reset link sent. Check your email." } async function updatePassword() { const client = getSupabaseClient() const password = authNewPasswordInput?.value if (!client || !password || password.length < 6) { authStatus.textContent = "New password must be at least 6 characters." return } const { error } = await client.auth.updateUser({ password }) authStatus.textContent = error ? readableAuthError(error) : "Password updated. You can keep playing." if (!error) setPasswordResetMode(false) } function setCreateAccountMode(isVisible) { if (authCreateFields) authCreateFields.hidden = !isVisible if (authSignUpButton) authSignUpButton.hidden = !isVisible if (authStatus && isVisible) { authStatus.textContent = "New here? Choose a username, keep the same email/password, and create your account." } } function setPasswordResetMode(isVisible) { if (authResetFields) authResetFields.hidden = !isVisible if (authForgotPasswordButton) authForgotPasswordButton.hidden = isVisible if (authStatus && isVisible) authStatus.textContent = "Choose a new password for your account." } function readableAuthError(error) { const message = String(error?.message || "Authentication failed.") const lowerMessage = message.toLowerCase() if (lowerMessage.includes("invalid login") || lowerMessage.includes("invalid credentials")) { return "Email or password is incorrect." } if (lowerMessage.includes("already registered") || lowerMessage.includes("already exists")) { return "That email already has an account. Sign in instead." } if (lowerMessage.includes("password")) { return "Password must be at least 6 characters." } if (lowerMessage.includes("rate limit")) { return "Too many attempts. Wait a few minutes and try again." } return message } function normalizeUsername(value) { return String(value || "") .trim() .toLowerCase() .replace(/[^a-z0-9_]/g, "") } async function signOut() { const client = getSupabaseClient() if (!client) return await client.auth.signOut() 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 || authModal?.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") } async 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…" const result = await resolveGuess(guess, activeTiles) if (!result) return const states = normalizeGuessStates(result.states) if (states.length !== WORD_LENGTH) { await recoverFromGuessError(activeTiles, "Guess could not be scored") return } currentGuessIndex = result.rowIndex updateTriesStatus() activeTiles.forEach((tile, index, tiles) => { flipTile(tile, index, tiles, guess, states[index], { ...result, states }) }) } async function resolveGuess(guess, activeTiles) { if (hourlyRound?.backend === "supabase") { try { return await submitRemoteGuess(guess) } catch (error) { console.warn("Failed to submit guess:", error) await recoverFromGuessError(activeTiles, error.message || "Guess could not be saved") return null } } const rowIndex = currentGuessIndex + 1 return { rowIndex, states: scoreGuess(guess, targetWord), completed: guess === targetWord || rowIndex === MAX_GUESSES, won: guess === targetWord, revealedWord: targetWord } } async function submitRemoteGuess(guess) { const client = getSupabaseClient() if (!client || !hourlyRound?.id) throw new Error("Sign in again to save this guess") const { data: { session }, error: sessionError } = await withTimeout( client.auth.getSession(), GUESS_TIMEOUT_MS, "Session check timed out. Try again." ) if (sessionError || !session) throw new Error("Please sign in again.") const { data, error } = await withTimeout( client.rpc("submit_guess", { target_round_id: hourlyRound.id, submitted_guess: guess }), GUESS_TIMEOUT_MS, "Connection timed out. Resyncing your round." ) if (error) throw error const row = Array.isArray(data) ? data[0] : data if (!row) throw new Error("No result returned") return { rowIndex: row.row_index, states: normalizeGuessStates(row.states), completed: row.completed, won: row.won, guessCount: row.guess_count, revealedWord: row.revealed_word, nextPlayableAt: Date.parse(row.next_playable_at) } } function withTimeout(promise, timeout, message) { let timeoutId const timeoutPromise = new Promise((_, reject) => { timeoutId = setTimeout(() => reject(new Error(message)), timeout) }) return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timeoutId)) } function normalizeGuessStates(states) { if (Array.isArray(states)) return states if (typeof states === "string") { try { const parsedStates = JSON.parse(states) return Array.isArray(parsedStates) ? parsedStates : [] } catch { return [] } } return [] } async function recoverFromGuessError(activeTiles, message) { showAlert(message) shakeTiles(activeTiles) roundStatus.textContent = "Try again" isAnimating = false if (hourlyRound?.backend === "supabase") await resyncRemoteRound() if (!gameFinished) startInteraction() } async function resyncRemoteRound() { try { const round = await startRemoteHourlyRound() if (round) applyHourlyRound(round) } catch (error) { console.warn("Failed to resync round:", error) } } 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, roundResult) { 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, roundResult) }, 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, roundResult) { if (roundResult?.revealedWord) { targetWord = roundResult.revealedWord hourlyRound = { ...hourlyRound, word: targetWord, completedAt: roundResult.completed ? Date.now() : null, won: roundResult.won, guessCount: roundResult.guessCount || currentGuessIndex, nextPlayableAt: roundResult.nextPlayableAt || hourlyRound.nextPlayableAt } } const wonRound = roundResult ? roundResult.completed && roundResult.won : guess === targetWord if (wonRound) { 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}` loadWordDefinition(targetWord, true) startLockCountdown(2400) showLeaderboardAfterCompletion() stopInteraction() return } const remainingTiles = guessGrid.querySelectorAll(":not([data-letter])") const lostRound = roundResult ? roundResult.completed && !roundResult.won : remainingTiles.length === 0 if (lostRound) { gameFinished = true lastResult = { won: false, guesses: MAX_GUESSES, word: targetWord } saveGameResult(lastResult) roundStatus.textContent = "Round complete" loadWordDefinition(targetWord, false) startLockCountdown(2400) showLeaderboardAfterCompletion() stopInteraction() return } roundStatus.textContent = "Keep going" startInteraction() } function updateTriesStatus() { triesStatus.textContent = `${currentGuessIndex} / ${MAX_GUESSES}` } function showLeaderboardAfterCompletion() { setTimeout(() => { if (statsModal.open || authModal?.open) return if (leaderboardModal?.open) return leaderboardScope = "hour" leaderboardTabs.forEach(tab => { tab.classList.toggle("active", tab.dataset.leaderboardScope === leaderboardScope) }) openLeaderboard() }, 3200) } 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" updateLeaderboardCountdown() } 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 loadWordDefinition(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") lastDefinition = { title: `${word.toUpperCase()} ${partOfSpeech ? `(${partOfSpeech})` : ""}`, body: definition, example, isWin } } catch (error) { console.info("Definition lookup unavailable:", error) lastDefinition = { title: word.toUpperCase(), body: "Definition not available at the moment.", isWin } } renderStatsDefinition() renderLeaderboardResult() return lastDefinition } 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") } async function openStats() { await renderStats() statsModal.showModal() } async function openLeaderboard() { renderLeaderboardLoading() renderLeaderboardResult() leaderboardModal?.showModal() await renderLeaderboard() } function switchLeaderboardScope(scope) { leaderboardScope = scope || "hour" leaderboardTabs.forEach(tab => { tab.classList.toggle("active", tab.dataset.leaderboardScope === leaderboardScope) }) renderLeaderboardLoading() renderLeaderboard() } 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) { if (hourlyRound?.backend === "supabase") return 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) if (!hourlyRound || hourlyRound.completedAt) return hourlyRound = { ...hourlyRound, completedAt: Date.now(), won: result.won, guessCount: result.guesses } saveLocalHourlyRound(hourlyRound) } async function renderStats() { const stats = await getDisplayStats() const summary = await getHourlySummary() 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("") renderStatsNote(summary) renderStatsDefinition() } function renderStatsNote(summary) { if (!statsNote) return if (!summary || summary.completedCount === 0) { statsNote.textContent = "No completed scores yet this hour." return } const average = summary.averageGuesses ? Number(summary.averageGuesses).toFixed(2) : "--" statsNote.textContent = `This hour: ${summary.completedCount} finishers · ${summary.winRate}% win rate · ${average} average guesses.` } async function getDisplayStats() { if (hourlyRound?.backend !== "supabase") return getStats() const remoteStats = await getRemoteStats() return remoteStats || getStats() } async function getRemoteStats() { const client = getSupabaseClient() if (!client || !authSession) return null const { data, error } = await client.rpc("get_user_stats") if (error) { console.warn("Failed to load synced stats:", error) return null } const row = Array.isArray(data) ? data[0] : data if (!row) return null return { played: row.played || 0, wins: row.wins || 0, currentStreak: row.current_streak || 0, maxStreak: row.max_streak || 0, distribution: Array.isArray(row.distribution) ? row.distribution : [0, 0, 0, 0, 0, 0] } } async function getHourlySummary() { const client = getSupabaseClient() if (!client) return null const { data, error } = await client.rpc("get_hourly_summary") if (error) { console.warn("Failed to load hourly summary:", error) return null } const row = Array.isArray(data) ? data[0] : data if (!row) return null return { completedCount: row.completed_count || 0, winRate: row.win_rate || 0, averageGuesses: row.average_guesses } } function renderStatsDefinition() { if (!statsDefinition) return if (!lastResult) { statsDefinition.innerHTML = "Finish this hour's word to reveal its definition here." return } if (!lastDefinition) { statsDefinition.innerHTML = "Loading definition..." return } statsDefinition.innerHTML = ` ${lastResult.won ? "Solved word" : "Answer"} ${escapeHtml(lastDefinition.title)} ${escapeHtml(lastDefinition.body)} ${lastDefinition.example ? `Example: ${escapeHtml(lastDefinition.example)}` : ""} ` } function renderLeaderboardLoading() { if (!leaderboardList) return leaderboardList.innerHTML = '
Loading this hour\'s finishers...
' updateLeaderboardCountdown() renderLeaderboardResult() } function renderLeaderboardResult() { if (!leaderboardResult) return if (!lastResult) { leaderboardResult.hidden = true leaderboardResult.innerHTML = "" return } leaderboardResult.hidden = false const status = lastResult.won ? `Solved in ${lastResult.guesses}/6` : "Round complete" const definition = lastDefinition ? ` ${escapeHtml(lastDefinition.body)} ${lastDefinition.example ? `Example: ${escapeHtml(lastDefinition.example)}` : ""} ` : "Loading definition..." leaderboardResult.innerHTML = `
${escapeHtml(lastResult.word || "Word reveal")} ${status}
${definition}
` } async function renderLeaderboard() { if (!leaderboardList) return const rows = await getLeaderboardRows(leaderboardScope) latestLeaderboardRows = rows updateLeaderboardCountdown() if (rows.length === 0) { leaderboardList.innerHTML = '
No finishers for this board yet. Be the first to land on it.
' return } leaderboardList.innerHTML = rows .map(row => `
${row.rank} ${escapeHtml(row.username)}${row.isCurrentUser ? " (you)" : ""} ${row.currentStreak} streak ${formatLeaderboardScore(row)}
`) .join("") } async function getLeaderboardRows(scope) { const client = getSupabaseClient() if (!client) return [] const { data, error } = await client.rpc("get_leaderboard", { board_scope: scope }) if (error) { console.warn("Failed to load leaderboard:", error) showAlert("Leaderboard unavailable") return [] } return (Array.isArray(data) ? data : []).map(row => ({ username: row.username || "player", won: Boolean(row.won), guessCount: row.guess_count || MAX_GUESSES, completedAt: row.completed_at, rank: row.rank, currentStreak: row.current_streak || 0, isCurrentUser: Boolean(row.is_current_user), wins: row.wins || 0, played: row.played || 0, averageGuesses: row.average_guesses })) } function formatLeaderboardScore(row) { if (leaderboardScope === "hour") { return `${row.won ? `${row.guessCount}/6` : "X/6"} · ${formatCompletedTime(row.completedAt)}` } const average = row.averageGuesses ? Number(row.averageGuesses).toFixed(2) : "--" return `${row.wins} wins · avg ${average}` } function updateLeaderboardCountdown() { if (!leaderboardCountdown) return if (leaderboardScope !== "hour") { leaderboardCountdown.textContent = leaderboardScope === "today" ? "Today ranks completed rounds since local midnight in the database timezone." : "All time ranks total wins, streaks, and average guesses." return } if (!hourlyRound?.nextPlayableAt) { leaderboardCountdown.textContent = "Next word unlocks at the top of the hour." return } const remaining = hourlyRound.nextPlayableAt - Date.now() leaderboardCountdown.textContent = remaining > 0 ? `Next word unlocks in ${formatRemainingTime(remaining)}.` : "The next hourly word is available." } function formatCompletedTime(value) { if (!value) return "--:--" return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(new Date(value)) } async function resetStats() { if (hourlyRound?.backend === "supabase") { showAlert("Synced stats are saved to your account") return } localStorage.removeItem(STATS_KEY) await renderStats() showAlert("Stats reset") } async function shareResult() { const text = await 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) } } async function buildShareText() { const status = lastResult ? `${lastResult.won ? lastResult.guesses : "X"}/${MAX_GUESSES}` : `${currentGuessIndex}/${MAX_GUESSES}` const roundLabel = getShareRoundLabel() const rankLabel = await getShareRankLabel() return `Fancy Wordle ${roundLabel} ${status}${rankLabel}\n${getResultGrid()}` } async function getShareRankLabel() { if (hourlyRound?.backend !== "supabase" || !lastResult) return "" let currentUserRow = leaderboardScope === "hour" ? latestLeaderboardRows.find(row => row.isCurrentUser) : null if (!currentUserRow) { currentUserRow = (await getLeaderboardRows("hour")).find(row => row.isCurrentUser) } return currentUserRow ? ` · #${currentUserRow.rank} this hour` : "" } 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 }