'use client'; import { useEffect, useState, useRef } from 'react'; import AudioPlayer, { AudioPlayerRef } from './AudioPlayer'; import GuessInput from './GuessInput'; import Statistics from './Statistics'; import { useGameState } from '../lib/gameState'; import { sendGotifyNotification, submitRating } from '../app/actions'; // Plausible Analytics declare global { interface Window { plausible?: (eventName: string, options?: { props?: Record }) => void; } } interface GameProps { dailyPuzzle: { id: number; puzzleNumber: number; audioUrl: string; songId: number; title: string; artist: string; coverImage: string | null; releaseYear?: number | null; startTime?: number; } | null; genre?: string | null; isSpecial?: boolean; maxAttempts?: number; unlockSteps?: number[]; } const DEFAULT_UNLOCK_STEPS = [2, 4, 7, 11, 16, 30, 60]; export default function Game({ dailyPuzzle, genre = null, isSpecial = false, maxAttempts = 7, unlockSteps = DEFAULT_UNLOCK_STEPS }: GameProps) { const { gameState, statistics, addGuess, giveUp, addReplay, addYearBonus, skipYearBonus } = useGameState(genre, maxAttempts); const [hasWon, setHasWon] = useState(false); const [hasLost, setHasLost] = useState(false); const [shareText, setShareText] = useState('🔗 Share'); const [lastAction, setLastAction] = useState<'GUESS' | 'SKIP' | null>(null); const [isProcessingGuess, setIsProcessingGuess] = useState(false); const [timeUntilNext, setTimeUntilNext] = useState(''); const [hasRated, setHasRated] = useState(false); const [showYearModal, setShowYearModal] = useState(false); const [hasPlayedAudio, setHasPlayedAudio] = useState(false); const audioPlayerRef = useRef(null); useEffect(() => { const updateCountdown = () => { const now = new Date(); const tomorrow = new Date(now); tomorrow.setHours(24, 0, 0, 0); const diff = tomorrow.getTime() - now.getTime(); const hours = Math.floor(diff / (1000 * 60 * 60)); const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); setTimeUntilNext(`${hours}h ${minutes}m`); }; updateCountdown(); const interval = setInterval(updateCountdown, 1000); return () => clearInterval(interval); }, []); useEffect(() => { if (gameState && dailyPuzzle) { setHasWon(gameState.isSolved); setHasLost(gameState.isFailed); // Show year modal if won but year not guessed yet and release year is available if (gameState.isSolved && !gameState.yearGuessed && dailyPuzzle.releaseYear) { setShowYearModal(true); } } }, [gameState, dailyPuzzle]); useEffect(() => { setLastAction(null); }, [dailyPuzzle?.id]); useEffect(() => { if (dailyPuzzle) { const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]'); if (ratedPuzzles.includes(dailyPuzzle.id)) { setHasRated(true); } else { setHasRated(false); } } }, [dailyPuzzle]); if (!dailyPuzzle) return (

No Puzzle Available

Could not generate a daily puzzle.

Please ensure there are songs in the database{genre ? ` for genre "${genre}"` : ''}.

Go to Admin Dashboard
); if (!gameState) return
Loading state...
; const handleGuess = (song: any) => { if (isProcessingGuess) return; setIsProcessingGuess(true); setLastAction('GUESS'); if (song.id === dailyPuzzle.songId) { addGuess(song.title, true); setHasWon(true); // Track puzzle solved event if (typeof window !== 'undefined' && window.plausible) { window.plausible('puzzle_solved', { props: { genre: genre || 'Global', attempts: gameState.guesses.length + 1, score: gameState.score + 20, // Include the win bonus outcome: 'won' } }); } // Notification sent after year guess or skip if (!dailyPuzzle.releaseYear) { sendGotifyNotification(gameState.guesses.length + 1, 'won', dailyPuzzle.id, genre, gameState.score); } } else { addGuess(song.title, false); if (gameState.guesses.length + 1 >= maxAttempts) { setHasLost(true); setHasWon(false); // Track puzzle lost event if (typeof window !== 'undefined' && window.plausible) { window.plausible('puzzle_solved', { props: { genre: genre || 'Global', attempts: maxAttempts, score: 0, outcome: 'lost' } }); } sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure } } setTimeout(() => setIsProcessingGuess(false), 500); }; const handleStartAudio = () => { // This will be called when user clicks "Start" button on first attempt // Trigger the audio player to start playing audioPlayerRef.current?.play(); setHasPlayedAudio(true); }; const handleSkip = () => { // If user hasn't played audio yet on first attempt, start it instead of skipping if (gameState.guesses.length === 0 && !hasPlayedAudio) { handleStartAudio(); return; } setLastAction('SKIP'); addGuess("SKIPPED", false); if (gameState.guesses.length + 1 >= maxAttempts) { setHasLost(true); setHasWon(false); // Track puzzle lost event if (typeof window !== 'undefined' && window.plausible) { window.plausible('puzzle_solved', { props: { genre: genre || 'Global', attempts: maxAttempts, score: 0, outcome: 'lost' } }); } sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); // Score is 0 on failure } }; const handleGiveUp = () => { setLastAction('SKIP'); addGuess("SKIPPED", false); giveUp(); // Ensure game is marked as failed and score reset to 0 setHasLost(true); setHasWon(false); // Track puzzle lost event if (typeof window !== 'undefined' && window.plausible) { window.plausible('puzzle_solved', { props: { genre: genre || 'Global', attempts: gameState.guesses.length + 1, score: 0, outcome: 'lost' } }); } sendGotifyNotification(maxAttempts, 'lost', dailyPuzzle.id, genre, 0); }; const handleYearGuess = (year: number) => { const correct = year === dailyPuzzle.releaseYear; addYearBonus(correct); setShowYearModal(false); // Update the puzzle_solved event with year bonus result if (typeof window !== 'undefined' && window.plausible) { window.plausible('puzzle_solved', { props: { genre: genre || 'Global', attempts: gameState.guesses.length, score: gameState.score + (correct ? 10 : 0), // Include year bonus if correct outcome: 'won', year_bonus: correct ? 'correct' : 'incorrect' } }); } // Send notification now that game is fully complete sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score + (correct ? 10 : 0)); }; const handleYearSkip = () => { skipYearBonus(); setShowYearModal(false); // Update the puzzle_solved event with year bonus result if (typeof window !== 'undefined' && window.plausible) { window.plausible('puzzle_solved', { props: { genre: genre || 'Global', attempts: gameState.guesses.length, score: gameState.score, // Score already includes win bonus outcome: 'won', year_bonus: 'skipped' } }); } // Send notification now that game is fully complete sendGotifyNotification(gameState.guesses.length, 'won', dailyPuzzle.id, genre, gameState.score); }; const unlockedSeconds = unlockSteps[Math.min(gameState.guesses.length, unlockSteps.length - 1)]; const handleShare = async () => { let emojiGrid = ''; const totalGuesses = maxAttempts; for (let i = 0; i < totalGuesses; i++) { if (i < gameState.guesses.length) { if (hasWon && i === gameState.guesses.length - 1) { emojiGrid += '🟩'; } else if (gameState.guesses[i] === 'SKIPPED') { emojiGrid += '⬛'; } else { emojiGrid += '🟥'; } } else { emojiGrid += '⬜'; } } const speaker = hasWon ? '🔉' : '🔇'; const bonusStar = (hasWon && gameState.yearGuessed && dailyPuzzle.releaseYear && gameState.scoreBreakdown.some(item => item.reason === 'Bonus: Correct Year')) ? '⭐' : ''; const genreText = genre ? `${isSpecial ? 'Special' : 'Genre'}: ${genre}\n` : ''; let shareUrl = 'https://hoerdle.elpatron.me'; if (genre) { if (isSpecial) { shareUrl += `/special/${encodeURIComponent(genre)}`; } else { shareUrl += `/${encodeURIComponent(genre)}`; } } const text = `Hördle #${dailyPuzzle.puzzleNumber}\n${genreText}\n${speaker}${emojiGrid}${bonusStar}\nScore: ${gameState.score}\n\n#Hördle #Music\n\n${shareUrl}`; const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); if (isMobile && navigator.share) { try { await navigator.share({ title: `Hördle #${dailyPuzzle.puzzleNumber}`, text: text, }); setShareText('✓ Shared!'); setTimeout(() => setShareText('🔗 Share'), 2000); return; } catch (err) { if ((err as Error).name !== 'AbortError') { console.error('Share failed:', err); } } } try { await navigator.clipboard.writeText(text); setShareText('✓ Copied!'); setTimeout(() => setShareText('🔗 Share'), 2000); } catch (err) { console.error('Clipboard failed:', err); setShareText('✗ Failed'); setTimeout(() => setShareText('🔗 Share'), 2000); } }; const handleRatingSubmit = async (rating: number) => { if (!dailyPuzzle) return; try { await submitRating(dailyPuzzle.songId, rating, genre, isSpecial, dailyPuzzle.puzzleNumber); setHasRated(true); const ratedPuzzles = JSON.parse(localStorage.getItem('hoerdle_rated_puzzles') || '[]'); if (!ratedPuzzles.includes(dailyPuzzle.id)) { ratedPuzzles.push(dailyPuzzle.id); localStorage.setItem('hoerdle_rated_puzzles', JSON.stringify(ratedPuzzles)); } } catch (error) { console.error('Failed to submit rating', error); } }; return (

Hördle #{dailyPuzzle.puzzleNumber}{genre ? ` / ${genre}` : ''}

Next puzzle in: {timeUntilNext}
Attempt {gameState.guesses.length + 1} / {maxAttempts} {unlockedSeconds}s unlocked
{gameState.guesses.map((guess, i) => { const isCorrect = hasWon && i === gameState.guesses.length - 1; return (
#{i + 1} {isCorrect ? 'Correct!' : guess}
); })}
{!hasWon && !hasLost && ( <>
{gameState.guesses.length < maxAttempts - 1 ? ( ) : ( )} )} {(hasWon || hasLost) && (

{hasWon ? 'You won!' : 'Game Over'}

Score: {gameState.score}
Score Breakdown
    {gameState.scoreBreakdown.map((item, i) => (
  • {item.reason} = 0 ? 'green' : 'red' }}> {item.value > 0 ? '+' : ''}{item.value}
  • ))}

{hasWon ? 'Come back tomorrow for a new song.' : 'The song was:'}

Album Cover

{dailyPuzzle.title}

{dailyPuzzle.artist}

{dailyPuzzle.releaseYear && gameState.yearGuessed && (

Released: {dailyPuzzle.releaseYear}

)}
{statistics && }
)}
{showYearModal && dailyPuzzle.releaseYear && ( )}
); } function ScoreDisplay({ score, breakdown }: { score: number, breakdown: Array<{ value: number, reason: string }> }) { const tooltipText = breakdown.map(item => `${item.reason}: ${item.value > 0 ? '+' : ''}${item.value}`).join('\n'); // Create expression: "90 - 2 - 5 + 10" // Limit to last 5 items to avoid overflow if too long const displayItems = breakdown.length > 5 ? [{ value: breakdown[0].value, reason: 'Start' }, ...breakdown.slice(-4)] : breakdown; const expression = displayItems.map((item, index) => { if (index === 0 && breakdown.length <= 5) return item.value.toString(); if (index === 0 && breakdown.length > 5) return `${item.value} ...`; return item.value >= 0 ? `+ ${item.value}` : `- ${Math.abs(item.value)}`; }).join(' '); return (
{expression} = {score}
); } function YearGuessModal({ correctYear, onGuess, onSkip }: { correctYear: number, onGuess: (year: number) => void, onSkip: () => void }) { const [options, setOptions] = useState([]); const [feedback, setFeedback] = useState<{ show: boolean, correct: boolean, guessedYear?: number }>({ show: false, correct: false }); useEffect(() => { const currentYear = new Date().getFullYear(); const minYear = 1950; const closeOptions = new Set(); closeOptions.add(correctYear); // Add 2 close years (+/- 2) while (closeOptions.size < 3) { const offset = Math.floor(Math.random() * 5) - 2; const year = correctYear + offset; if (year <= currentYear && year >= minYear && year !== correctYear) { closeOptions.add(year); } } const allOptions = new Set(closeOptions); // Fill up to 10 with random years while (allOptions.size < 10) { const year = Math.floor(Math.random() * (currentYear - minYear + 1)) + minYear; allOptions.add(year); } setOptions(Array.from(allOptions).sort((a, b) => a - b)); }, [correctYear]); const handleGuess = (year: number) => { const correct = year === correctYear; setFeedback({ show: true, correct, guessedYear: year }); // Close modal after showing feedback setTimeout(() => { onGuess(year); }, 2500); }; const handleSkip = () => { setFeedback({ show: true, correct: false }); setTimeout(() => { onSkip(); }, 2000); }; return (
{!feedback.show ? ( <>

Bonus Round!

Guess the release year for +10 points!

{options.map(year => ( ))}
) : (
{feedback.guessedYear ? ( feedback.correct ? ( <>
🎉

Correct!

Released in {correctYear}

+10 Points!

) : ( <>
😕

Not quite!

You guessed {feedback.guessedYear}

Actually released in {correctYear}

) ) : ( <>
⏭️

Skipped

Released in {correctYear}

)}
)}
); } function StarRating({ onRate, hasRated }: { onRate: (rating: number) => void, hasRated: boolean }) { const [hover, setHover] = useState(0); const [rating, setRating] = useState(0); if (hasRated) { return
Thanks for rating!
; } return (
Rate this puzzle:
{[...Array(5)].map((_, index) => { const ratingValue = index + 1; return ( ); })}
); }