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 = []
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 leaderboardButton = document.getElementById("leaderboard-button")
const leaderboardModal = document.getElementById("leaderboard-modal")
const leaderboardList = document.getElementById("leaderboard-list")
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 historyList = document.getElementById("history-list")
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(async (event, session) => {
authSession = session
authProfile = session ? await getAuthProfile() : null
updateAuthControls()
if (event === "PASSWORD_RECOVERY") {
openAuth()
setPasswordResetMode(true)
return
}
if (event === "SIGNED_IN") window.location.reload()
})
} 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)
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 || ""
}
lockUntilNextWord()
return
}
roundStatus.textContent = round.isExisting ? "Hourly word resumed" : "Hourly word ready"
startInteraction()
}
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.`
}
}
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) {
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)
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, error } = await client.rpc("submit_guess", {
target_round_id: hourlyRound.id,
submitted_guess: guess
})
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 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 []
}
function recoverFromGuessError(activeTiles, message) {
showAlert(message)
shakeTiles(activeTiles)
roundStatus.textContent = "Try again"
isAnimating = false
startInteraction()
}
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}`
setTimeout(() => showWordDefinition(targetWord, true), 1800)
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)
showAlert(targetWord.toUpperCase(), null)
roundStatus.textContent = "Round complete"
showWordDefinition(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
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 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")
}
async function openStats() {
await renderStats()
statsModal.showModal()
}
async function openLeaderboard() {
renderLeaderboardLoading()
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]) => `