Initial commit

This commit is contained in:
Joey Yakimowich-Payne 2026-01-13 07:23:30 -07:00
commit c87ebf0a74
No known key found for this signature in database
GPG key ID: 6BFE655FA5ABD1E1
22 changed files with 4973 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

147
App.tsx Normal file
View file

@ -0,0 +1,147 @@
import React from 'react';
import { useGame } from './hooks/useGame';
import { Landing } from './components/Landing';
import { Lobby } from './components/Lobby';
import { GameScreen } from './components/GameScreen';
import { Scoreboard } from './components/Scoreboard';
import { Podium } from './components/Podium';
import { QuizCreator } from './components/QuizCreator';
import { RevealScreen } from './components/RevealScreen';
const FloatingShapes = () => {
// Deterministic "random" for SSR safety if needed, but client-side is fine here
const shapes = [...Array(15)].map((_, i) => ({
left: `${Math.random() * 100}%`,
width: `${Math.random() * 100 + 40}px`,
height: `${Math.random() * 100 + 40}px`,
animationDuration: `${Math.random() * 20 + 15}s`,
animationDelay: `-${Math.random() * 20}s`,
borderRadius: Math.random() > 0.5 ? '50%' : '20%', // Mix of circles and rounded squares
background: 'rgba(255, 255, 255, 0.05)',
}));
return (
<>
{shapes.map((style, i) => (
<div key={i} className="floating-shape" style={style} />
))}
</>
);
};
function App() {
const {
role,
gameState,
quiz,
players,
currentQuestionIndex,
timeLeft,
error,
gamePin,
startQuizGen,
startManualCreation,
finalizeManualQuiz,
joinGame,
startGame,
handleAnswer,
hasAnswered,
lastPointsEarned,
nextQuestion,
currentCorrectShape
} = useGame();
const currentQ = quiz?.questions[currentQuestionIndex];
// Logic to find correct option, handling both Host (has isCorrect flag) and Client (masked, needs shape)
const correctOpt = currentQ?.options.find(o => {
if (role === 'HOST') return o.isCorrect;
return o.shape === currentCorrectShape;
});
return (
<div className="min-h-screen text-white relative">
<FloatingShapes />
<div className="relative z-10">
{gameState === 'LANDING' || gameState === 'GENERATING' ? (
<Landing
onGenerate={startQuizGen}
onCreateManual={startManualCreation}
onJoin={joinGame}
isLoading={gameState === 'GENERATING'}
error={error}
/>
) : null}
{gameState === 'CREATING' ? (
<QuizCreator
onFinalize={finalizeManualQuiz}
onCancel={() => window.location.reload()}
/>
) : null}
{gameState === 'LOBBY' ? (
<Lobby
quizTitle={quiz?.title || 'OpenHoot'}
players={players}
gamePin={gamePin}
role={role}
onStart={startGame}
/>
) : null}
{(gameState === 'COUNTDOWN' || gameState === 'QUESTION') && quiz ? (
gameState === 'COUNTDOWN' ? (
<div className="flex flex-col items-center justify-center h-screen animate-bounce">
<div className="text-4xl font-display font-bold mb-4">Get Ready!</div>
<div className="text-[12rem] font-black leading-none drop-shadow-[0_10px_0_rgba(0,0,0,0.3)]">
{timeLeft}
</div>
</div>
) : (
<GameScreen
question={quiz.questions[currentQuestionIndex]}
timeLeft={timeLeft}
totalQuestions={quiz.questions.length}
currentQuestionIndex={currentQuestionIndex}
gameState={gameState}
role={role}
onAnswer={handleAnswer}
hasAnswered={hasAnswered}
lastPointsEarned={lastPointsEarned}
/>
)
) : null}
{gameState === 'REVEAL' && correctOpt ? (
<RevealScreen
isCorrect={lastPointsEarned !== null && lastPointsEarned > 0}
pointsEarned={lastPointsEarned || 0}
newScore={0}
streak={0}
correctOption={correctOpt}
role={role}
/>
) : null}
{gameState === 'SCOREBOARD' ? (
<Scoreboard
players={players}
onNext={nextQuestion}
isHost={role === 'HOST'}
/>
) : null}
{gameState === 'PODIUM' ? (
<Podium
players={players}
onRestart={() => window.location.reload()}
/>
) : null}
</div>
</div>
);
}
export default App;

20
README.md Normal file
View file

@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/drive/1N0ITrr45ZWdQvXMQNxOULCmJBQyaiWH8
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

142
components/GameScreen.tsx Normal file
View file

@ -0,0 +1,142 @@
import React from 'react';
import { Question, AnswerOption, GameState, GameRole } from '../types';
import { COLORS, SHAPES } from '../constants';
import { motion, AnimatePresence } from 'framer-motion';
interface GameScreenProps {
question?: Question;
timeLeft: number;
totalQuestions: number;
currentQuestionIndex: number;
gameState: GameState;
role: GameRole;
onAnswer: (isCorrect: boolean) => void;
hasAnswered: boolean;
lastPointsEarned: number | null;
}
export const GameScreen: React.FC<GameScreenProps> = ({
question,
timeLeft,
totalQuestions,
currentQuestionIndex,
gameState,
role,
onAnswer,
hasAnswered,
}) => {
const isClient = role === 'CLIENT';
const displayOptions = question?.options || [];
// Timer styling logic
const isUrgent = timeLeft < 5 && timeLeft > 0;
const timerBorderColor = isUrgent ? 'border-red-500' : 'border-white';
const timerTextColor = isUrgent ? 'text-red-500' : 'text-[#46178f]';
const timerAnimation = isUrgent ? 'animate-ping' : '';
return (
<div className="flex flex-col h-screen max-h-screen overflow-hidden relative">
{/* Header */}
<div className="flex justify-between items-center p-4 md:p-6">
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
{currentQuestionIndex + 1} / {totalQuestions}
</div>
{/* Whimsical Timer */}
<div className="relative">
<div className="absolute inset-0 bg-white/20 rounded-full blur-xl animate-pulse"></div>
<div className={`bg-white ${timerTextColor} rounded-full w-20 h-20 flex items-center justify-center text-4xl font-black shadow-[0_6px_0_rgba(0,0,0,0.2)] border-4 ${timerBorderColor} ${timerAnimation} relative z-10 transition-colors duration-300`}>
{timeLeft}
</div>
</div>
<div className="bg-white/20 backdrop-blur-md px-6 py-2 rounded-2xl font-black text-xl shadow-sm border-2 border-white/10">
{isClient ? 'Controller' : 'Host'}
</div>
</div>
{/* Question Area */}
<div className="flex-1 flex flex-col items-center justify-center p-4 md:p-8 text-center relative z-10">
{question && (
<motion.div
key={question.id}
initial={{ y: 20, opacity: 0, scale: 0.95 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="bg-white text-black p-8 md:p-12 rounded-[2rem] shadow-[0_12px_0_rgba(0,0,0,0.1)] max-w-5xl w-full border-b-8 border-gray-200"
>
<h2 className="text-2xl md:text-5xl font-black text-[#333] font-display leading-tight">
{question.text}
</h2>
</motion.div>
)}
</div>
{/* Answer Grid */}
<div className="grid grid-cols-2 gap-3 md:gap-6 p-3 md:p-6 h-1/2 md:h-[45vh] pb-8 md:pb-12">
{displayOptions.map((option, idx) => {
const ShapeIcon = SHAPES[option.shape];
const colorClass = COLORS[option.color];
let opacityClass = "opacity-100";
let scaleClass = "scale-100";
// If answering phase and user answered, dim everything
if (hasAnswered) {
opacityClass = "opacity-50 cursor-not-allowed grayscale";
scaleClass = "scale-95";
}
return (
<motion.button
key={idx}
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: idx * 0.03, type: 'spring', stiffness: 500, damping: 30 }}
disabled={hasAnswered}
onClick={() => onAnswer(option as any)}
className={`
${colorClass} ${opacityClass} ${scaleClass}
rounded-3xl shadow-[0_8px_0_rgba(0,0,0,0.2)]
flex flex-col md:flex-row items-center justify-center md:justify-start
p-4 md:p-8
active:shadow-none active:translate-y-[8px] active:scale-95
transition-all duration-300 relative group overflow-hidden border-b-8 border-black/10
`}
>
<ShapeIcon className="absolute -right-6 -bottom-6 w-32 h-32 text-black/10 rotate-12 group-hover:rotate-45 transition-transform duration-500" />
<div className="flex-shrink-0 mb-2 md:mb-0 md:mr-6 bg-black/20 p-3 md:p-4 rounded-2xl shadow-inner">
<ShapeIcon className="w-8 h-8 md:w-12 md:h-12 text-white" fill="currentColor" />
</div>
<span className="text-lg md:text-3xl font-black text-white text-center md:text-left drop-shadow-md leading-tight relative z-10">
{option.text}
</span>
</motion.button>
);
})}
</div>
{/* "Answer Sent" Overlay */}
<AnimatePresence>
{isClient && hasAnswered && (
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
className="absolute inset-0 bg-[#46178f]/95 flex flex-col items-center justify-center z-50 p-8 text-center"
>
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Infinity, duration: 1.5 }}
className="text-6xl mb-6"
>
🚀
</motion.div>
<h2 className="text-4xl md:text-5xl font-black text-white font-display mb-4">Answer Sent!</h2>
<p className="text-xl font-bold opacity-80">Cross your fingers...</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
};

127
components/Landing.tsx Normal file
View file

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { BrainCircuit, Loader2, Users, Play, PenTool } from 'lucide-react';
interface LandingProps {
onGenerate: (topic: string) => void;
onCreateManual: () => void;
onJoin: (pin: string, name: string) => void;
isLoading: boolean;
error: string | null;
}
export const Landing: React.FC<LandingProps> = ({ onGenerate, onCreateManual, onJoin, isLoading, error }) => {
const [mode, setMode] = useState<'HOST' | 'JOIN'>('HOST');
const [topic, setTopic] = useState('');
const [pin, setPin] = useState('');
const [name, setName] = useState('');
const handleHostSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (topic.trim()) onGenerate(topic);
};
const handleJoinSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (pin.trim() && name.trim()) onJoin(pin, name);
};
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4 text-center">
<motion.div
initial={{ scale: 0.8, opacity: 0, rotate: -2 }}
animate={{ scale: 1, opacity: 1, rotate: 0 }}
transition={{ type: "spring", bounce: 0.5 }}
className="bg-white text-gray-900 p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] max-w-md w-full border-4 border-white/50"
>
<div className="flex justify-center mb-6">
<div className="bg-[#46178f] p-4 rounded-3xl rotate-3 shadow-lg">
<BrainCircuit size={48} className="text-white" />
</div>
</div>
<h1 className="text-5xl font-black mb-2 text-[#46178f] tracking-tight">OpenHoot</h1>
<p className="text-gray-500 font-bold mb-6">The AI Quiz Party</p>
<div className="flex bg-gray-100 p-2 rounded-2xl mb-8">
<button
onClick={() => setMode('HOST')}
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'HOST' ? 'bg-white shadow-md text-[#46178f] scale-105' : 'text-gray-400 hover:text-gray-600'}`}
>
Host
</button>
<button
onClick={() => setMode('JOIN')}
className={`flex-1 py-3 rounded-xl font-black text-lg transition-all duration-200 ${mode === 'JOIN' ? 'bg-white shadow-md text-[#46178f] scale-105' : 'text-gray-400 hover:text-gray-600'}`}
>
Join
</button>
</div>
{mode === 'HOST' ? (
<div className="space-y-6">
<form onSubmit={handleHostSubmit} className="space-y-4">
<input
type="text"
placeholder="Topic (e.g. 'Space')"
value={topic}
onChange={(e) => setTopic(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none transition-all placeholder:font-medium text-center"
disabled={isLoading}
/>
<button
type="submit"
disabled={isLoading || !topic.trim()}
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
>
{isLoading ? <Loader2 className="animate-spin" /> : <><BrainCircuit size={24} /> Generate Quiz</>}
</button>
</form>
<div className="relative flex py-2 items-center opacity-50">
<div className="flex-grow border-t-2 border-gray-300"></div>
<span className="flex-shrink mx-4 text-gray-400 font-bold">OR</span>
<div className="flex-grow border-t-2 border-gray-300"></div>
</div>
<button
onClick={onCreateManual}
className="w-full bg-white border-2 border-[#46178f] text-[#46178f] py-3 rounded-2xl text-lg font-black hover:bg-purple-50 shadow-[0_4px_0_#46178f] active:shadow-none active:translate-y-[4px] transition-all flex items-center justify-center gap-2"
>
<PenTool size={20} /> Create Manually
</button>
</div>
) : (
<form onSubmit={handleJoinSubmit} className="space-y-4">
<input
type="text"
placeholder="Game PIN"
value={pin}
onChange={(e) => setPin(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none text-center"
/>
<input
type="text"
placeholder="Nickname"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full p-4 text-xl font-bold border-2 border-gray-200 rounded-2xl focus:border-[#46178f] focus:ring-4 focus:ring-[#46178f]/20 outline-none text-center"
/>
<button
type="submit"
disabled={!pin.trim() || !name.trim()}
className="w-full bg-[#333] text-white py-4 rounded-2xl text-xl font-black shadow-[0_6px_0_#000] active:shadow-none active:translate-y-[6px] transition-all hover:bg-black flex items-center justify-center gap-3"
>
<Play fill="currentColor" /> Join Game
</button>
</form>
)}
{error && (
<motion.div initial={{ height: 0 }} animate={{ height: 'auto' }} className="overflow-hidden mt-4">
<p className="text-red-500 font-bold bg-red-50 p-3 rounded-xl border-2 border-red-100">{error}</p>
</motion.div>
)}
</motion.div>
</div>
);
};

103
components/Lobby.tsx Normal file
View file

@ -0,0 +1,103 @@
import React from 'react';
import { Player } from '../types';
import { motion, AnimatePresence } from 'framer-motion';
import { User, Sparkles } from 'lucide-react';
interface LobbyProps {
quizTitle: string;
players: Player[];
gamePin: string | null;
role: 'HOST' | 'CLIENT';
onStart: () => void;
}
export const Lobby: React.FC<LobbyProps> = ({ quizTitle, players, gamePin, role, onStart }) => {
const isHost = role === 'HOST';
const realPlayers = players.filter(p => p.id !== 'host');
return (
<div className="flex flex-col min-h-screen p-6">
<header className="flex flex-col md:flex-row justify-between items-center bg-white/10 p-6 rounded-[2rem] backdrop-blur-md mb-8 gap-6 border-4 border-white/20 shadow-xl">
<div className="flex flex-col items-center md:items-start">
<span className="text-white/80 font-bold uppercase tracking-widest text-sm mb-1">Game PIN</span>
<div className="text-5xl md:text-6xl font-black bg-white text-[#46178f] px-8 py-2 rounded-full shadow-[0_6px_0_rgba(0,0,0,0.2)] tracking-wider">
{gamePin}
</div>
</div>
<div className="text-center">
<div className="text-3xl font-black font-display mb-2">{quizTitle}</div>
<div className="inline-flex items-center gap-2 bg-[#00000040] px-4 py-1 rounded-full">
<div className="w-3 h-3 bg-green-400 rounded-full animate-pulse"></div>
<span className="font-bold">Live Lobby</span>
</div>
</div>
<div className="bg-white/20 px-6 py-3 rounded-2xl font-black text-2xl flex flex-col items-center min-w-[120px]">
<span>{realPlayers.length}</span>
<span className="text-sm font-bold opacity-80 uppercase">Players</span>
</div>
</header>
<main className="flex-1 flex flex-col items-center justify-center">
{isHost ? (
<>
<div className="flex flex-wrap gap-4 justify-center w-full max-w-6xl mb-12 min-h-[200px] content-start">
<AnimatePresence>
{realPlayers.length === 0 && (
<div className="flex flex-col items-center opacity-60 mt-12">
<div className="bg-white/10 p-6 rounded-full mb-4 animate-bounce">
<Sparkles size={48} />
</div>
<div className="text-3xl font-bold font-display">Waiting for players to join...</div>
</div>
)}
{realPlayers.map((player) => (
<motion.div
key={player.id}
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, opacity: 0 }}
className="bg-white text-black px-6 py-3 rounded-full font-black text-xl shadow-[0_4px_0_rgba(0,0,0,0.2)] flex items-center gap-3 border-b-4 border-gray-200"
>
<div className={`p-2 rounded-full bg-gradient-to-br from-purple-400 to-blue-500 text-white`}>
<User size={20} />
</div>
{player.name}
</motion.div>
))}
</AnimatePresence>
</div>
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
className="fixed bottom-8"
>
<button
onClick={onStart}
disabled={realPlayers.length === 0}
className="bg-white text-[#46178f] px-16 py-5 rounded-full text-3xl font-black hover:scale-105 active:scale-95 transition-all shadow-[0_8px_0_rgba(0,0,0,0.2)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none disabled:translate-y-2"
>
Start Game
</button>
</motion.div>
</>
) : (
<div className="flex flex-col items-center justify-center h-full text-center p-8">
<motion.div
initial={{ scale: 0.5 }}
animate={{ scale: 1 }}
transition={{ type: 'spring', bounce: 0.6 }}
className="bg-white text-[#46178f] p-8 rounded-[2rem] shadow-[0_10px_0_rgba(0,0,0,0.1)] mb-8"
>
<User size={80} strokeWidth={2.5} />
</motion.div>
<h2 className="text-5xl font-black mb-4 font-display">You're in!</h2>
<p className="text-2xl font-bold opacity-80">See your name on the big screen?</p>
</div>
)}
</main>
</div>
);
};

97
components/Podium.tsx Normal file
View file

@ -0,0 +1,97 @@
import React, { useEffect } from 'react';
import { Player } from '../types';
import { motion } from 'framer-motion';
import { Trophy, Medal, RotateCcw } from 'lucide-react';
import confetti from 'canvas-confetti';
interface PodiumProps {
players: Player[];
onRestart: () => void;
}
export const Podium: React.FC<PodiumProps> = ({ players, onRestart }) => {
const sorted = [...players].sort((a, b) => b.score - a.score);
const winner = sorted[0];
const second = sorted[1];
const third = sorted[2];
useEffect(() => {
const duration = 3 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
const interval: any = setInterval(function() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) return clearInterval(interval);
const particleCount = 50 * (timeLeft / duration);
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
}, 250);
return () => clearInterval(interval);
}, []);
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4">
<h1 className="text-6xl font-black text-white mb-12 font-display drop-shadow-[0_5px_0_rgba(0,0,0,0.3)] tracking-wide">Podium</h1>
<div className="flex items-end justify-center gap-4 md:gap-8 mb-12 w-full max-w-4xl h-96">
{/* Second Place */}
{second && (
<motion.div
initial={{ height: 0 }}
animate={{ height: "60%" }}
transition={{ type: 'spring', bounce: 0.5, delay: 0.5 }}
className="w-1/3 bg-gray-200 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative border-x-4 border-t-4 border-white/50 shadow-xl"
>
<div className="absolute -top-16 flex flex-col items-center">
<span className="text-xl font-bold mb-2 text-white drop-shadow-md">{second.name}</span>
<div className="bg-gray-300 p-4 rounded-full text-white shadow-lg"><Medal size={32} /></div>
</div>
<span className="text-3xl font-black text-gray-500 mb-4">{second.score}</span>
</motion.div>
)}
{/* First Place */}
{winner && (
<motion.div
initial={{ height: 0 }}
animate={{ height: "80%" }}
transition={{ type: 'spring', bounce: 0.6, delay: 1 }}
className="w-1/3 bg-yellow-400 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative z-10 shadow-2xl border-x-4 border-t-4 border-white/50"
>
<div className="absolute -top-24 flex flex-col items-center">
<span className="text-3xl font-bold mb-2 text-yellow-100 drop-shadow-md">{winner.name}</span>
<div className="bg-yellow-500 p-6 rounded-full text-white shadow-lg scale-110"><Trophy size={48} /></div>
</div>
<span className="text-5xl font-black text-yellow-900 mb-6">{winner.score}</span>
</motion.div>
)}
{/* Third Place */}
{third && (
<motion.div
initial={{ height: 0 }}
animate={{ height: "40%" }}
transition={{ type: 'spring', bounce: 0.5, delay: 0 }}
className="w-1/3 bg-orange-400 rounded-t-[3rem] flex flex-col items-center justify-end p-6 relative border-x-4 border-t-4 border-white/50 shadow-xl"
>
<div className="absolute -top-16 flex flex-col items-center">
<span className="text-xl font-bold mb-2 text-white drop-shadow-md">{third.name}</span>
<div className="bg-orange-500 p-4 rounded-full text-white shadow-lg"><Medal size={32} /></div>
</div>
<span className="text-3xl font-black text-orange-900 mb-4">{third.score}</span>
</motion.div>
)}
</div>
<button
onClick={onRestart}
className="flex items-center gap-3 bg-white text-[#46178f] px-10 py-4 rounded-2xl text-2xl font-black hover:scale-105 transition shadow-[0_8px_0_rgba(0,0,0,0.2)] active:shadow-none active:translate-y-[8px]"
>
<RotateCcw size={28} /> Play Again
</button>
</div>
);
};

172
components/QuizCreator.tsx Normal file
View file

@ -0,0 +1,172 @@
import React, { useState } from 'react';
import { Quiz, Question, AnswerOption } from '../types';
import { v4 as uuidv4 } from 'uuid';
import { Plus, Save, Trash2, CheckCircle, Circle, X } from 'lucide-react';
import { COLORS, SHAPES } from '../constants';
interface QuizCreatorProps {
onFinalize: (quiz: Quiz) => void;
onCancel: () => void;
}
export const QuizCreator: React.FC<QuizCreatorProps> = ({ onFinalize, onCancel }) => {
const [title, setTitle] = useState('');
const [questions, setQuestions] = useState<Question[]>([]);
const [qText, setQText] = useState('');
const [options, setOptions] = useState<string[]>(['', '', '', '']);
const [correctIdx, setCorrectIdx] = useState<number>(0);
const handleAddQuestion = () => {
if (!qText.trim() || options.some(o => !o.trim())) {
alert("Please fill in the question and all 4 options.");
return;
}
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
const newOptions: AnswerOption[] = options.map((text, idx) => ({
text,
isCorrect: idx === correctIdx,
shape: shapes[idx],
color: colors[idx]
}));
const newQuestion: Question = {
id: uuidv4(),
text: qText,
options: newOptions,
timeLimit: 20
};
setQuestions([...questions, newQuestion]);
setQText('');
setOptions(['', '', '', '']);
setCorrectIdx(0);
};
const handleRemoveQuestion = (id: string) => {
setQuestions(questions.filter(q => q.id !== id));
};
const handleFinalize = () => {
if (!title.trim() || questions.length === 0) return;
onFinalize({ title, questions });
};
return (
<div className="min-h-screen bg-gray-100 text-gray-900 p-4 md:p-8 flex flex-col items-center">
<div className="max-w-4xl w-full bg-white rounded-[2rem] shadow-xl overflow-hidden border-4 border-white">
<div className="bg-[#46178f] p-8 text-white flex justify-between items-center relative overflow-hidden">
<div className="relative z-10">
<h2 className="text-4xl font-black font-display">Create Quiz</h2>
<p className="opacity-80 font-bold">Build your masterpiece</p>
</div>
<button
onClick={onCancel}
className="bg-white/20 p-3 rounded-full hover:bg-white/30 transition relative z-10"
>
<X size={24} />
</button>
{/* Decorative Circles */}
<div className="absolute -right-10 -top-10 w-40 h-40 bg-white/10 rounded-full"></div>
<div className="absolute right-20 bottom-[-50px] w-24 h-24 bg-white/10 rounded-full"></div>
</div>
<div className="p-8 space-y-8">
<div>
<label className="block text-sm font-black uppercase tracking-wider text-gray-500 mb-2 ml-2">Quiz Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full p-4 border-4 border-gray-200 rounded-2xl text-2xl font-bold focus:border-[#46178f] outline-none transition-colors"
placeholder="e.g., The Ultimate Trivia"
/>
</div>
<div className="space-y-4">
{questions.map((q, idx) => (
<div key={q.id} className="border-4 border-gray-100 p-4 rounded-2xl bg-gray-50 flex justify-between items-center group hover:border-gray-200 transition">
<div className="flex items-center gap-4">
<span className="bg-[#46178f] text-white w-10 h-10 flex items-center justify-center rounded-full font-black">
{idx + 1}
</span>
<span className="font-bold text-lg">{q.text}</span>
</div>
<button onClick={() => handleRemoveQuestion(q.id)} className="text-gray-400 hover:text-red-500 p-2 rounded-xl hover:bg-red-50 transition">
<Trash2 size={24} />
</button>
</div>
))}
</div>
<div className="bg-blue-50/50 p-6 md:p-8 rounded-[2rem] border-4 border-blue-100">
<h3 className="text-xl font-black mb-6 flex items-center gap-2 text-blue-900">
<Plus size={24} /> New Question
</h3>
<div className="mb-6">
<input
type="text"
value={qText}
onChange={(e) => setQText(e.target.value)}
className="w-full p-4 border-4 border-white shadow-sm rounded-2xl font-bold text-lg focus:border-blue-400 outline-none"
placeholder="What is the question?"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{options.map((opt, idx) => {
const isSelected = correctIdx === idx;
const borderColor = isSelected ? 'border-green-500' : 'border-white';
const bgClass = COLORS[['red','blue','yellow','green'][idx] as any];
return (
<div key={idx} className={`flex items-center gap-3 p-3 rounded-2xl border-4 ${borderColor} bg-white shadow-sm transition-all`}>
<button
onClick={() => setCorrectIdx(idx)}
className={`p-1 rounded-full ${isSelected ? 'text-green-500' : 'text-gray-200 hover:text-gray-400'}`}
>
{isSelected ? <CheckCircle size={32} fill="currentColor" className="text-white" /> : <Circle size={32} />}
</button>
<div className={`w-4 h-full rounded-full ${bgClass}`}></div>
<input
type="text"
value={opt}
onChange={(e) => {
const newOpts = [...options];
newOpts[idx] = e.target.value;
setOptions(newOpts);
}}
className="w-full p-2 outline-none font-bold text-gray-700 bg-transparent placeholder:font-normal"
placeholder={`Option ${idx + 1}`}
/>
</div>
)
})}
</div>
<button
onClick={handleAddQuestion}
className="mt-8 w-full bg-blue-600 text-white py-4 rounded-2xl font-black text-lg hover:bg-blue-700 shadow-[0_6px_0_#1e40af] active:shadow-none active:translate-y-[6px] transition-all"
>
Add Question
</button>
</div>
</div>
<div className="p-6 bg-gray-50 border-t-2 border-gray-100 flex justify-end">
<button
onClick={handleFinalize}
className="flex items-center gap-2 bg-green-500 text-white px-10 py-4 rounded-2xl text-xl font-black hover:bg-green-600 shadow-[0_6px_0_#15803d] active:shadow-none active:translate-y-[6px] transition-all"
>
<Save size={24} /> Finish & Play
</button>
</div>
</div>
</div>
);
};

156
components/RevealScreen.tsx Normal file
View file

@ -0,0 +1,156 @@
import React, { useEffect } from 'react';
import { motion } from 'framer-motion';
import { Check, X, Flame, Trophy } from 'lucide-react';
import { AnswerOption, Player, GameRole } from '../types';
import { SHAPES, COLORS } from '../constants';
import confetti from 'canvas-confetti';
interface RevealScreenProps {
isCorrect: boolean;
pointsEarned: number;
newScore: number;
streak: number;
correctOption: AnswerOption;
role: GameRole;
}
export const RevealScreen: React.FC<RevealScreenProps> = ({
isCorrect,
pointsEarned,
newScore,
streak,
correctOption,
role
}) => {
const isHost = role === 'HOST';
// Trigger confetti for correct answers
useEffect(() => {
if (isCorrect && !isHost) {
confetti({
particleCount: 100,
spread: 70,
origin: { y: 0.6 },
colors: ['#22c55e', '#ffffff', '#fbbf24']
});
}
}, [isCorrect, isHost]);
// -- HOST VIEW --
if (isHost) {
const ShapeIcon = SHAPES[correctOption.shape];
const colorClass = COLORS[correctOption.color];
return (
<div className="flex flex-col items-center justify-center h-screen bg-gray-900 text-white p-8 relative overflow-hidden">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-10"></div>
<motion.div
initial={{ opacity: 0, y: -50 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold uppercase tracking-widest mb-12 opacity-80"
>
The correct answer is
</motion.div>
<motion.div
initial={{ scale: 0, rotate: -10 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", bounce: 0.5 }}
className={`${colorClass} p-12 rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.3)] flex flex-col items-center max-w-4xl w-full border-8 border-white/20`}
>
<div className="bg-black/20 p-6 rounded-full mb-6">
<ShapeIcon size={80} fill="currentColor" />
</div>
<h1 className="text-5xl md:text-7xl font-black font-display text-center drop-shadow-md leading-tight">
{correctOption.text}
</h1>
</motion.div>
</div>
);
}
// -- CLIENT VIEW --
const bgColor = isCorrect ? 'bg-[#22c55e]' : 'bg-[#ef4444]';
const darkerColor = isCorrect ? 'bg-[#15803d]' : 'bg-[#b91c1c]';
const ShapeIcon = SHAPES[correctOption.shape];
return (
<div className={`flex flex-col items-center justify-center h-screen ${bgColor} text-white p-6 relative overflow-hidden transition-colors duration-500`}>
{/* Dynamic Background Circles */}
<motion.div
animate={{ scale: [1, 1.2, 1], opacity: [0.1, 0.2, 0.1] }}
transition={{ repeat: Infinity, duration: 4 }}
className="absolute w-[800px] h-[800px] bg-white rounded-full blur-3xl opacity-10 pointer-events-none"
/>
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", bounce: 0.6 }}
className="flex flex-col items-center z-10"
>
<div className="bg-white p-6 rounded-full shadow-[0_10px_0_rgba(0,0,0,0.2)] mb-8">
{isCorrect ? (
<Check size={80} className="text-[#22c55e]" strokeWidth={4} />
) : (
<X size={80} className="text-[#ef4444]" strokeWidth={4} />
)}
</div>
<h1 className="text-6xl md:text-8xl font-black font-display mb-4 drop-shadow-lg text-center">
{isCorrect ? "Correct!" : "Incorrect"}
</h1>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.2 }}
className="flex flex-col items-center"
>
{isCorrect ? (
<div className="bg-black/20 px-8 py-4 rounded-3xl backdrop-blur-sm border-4 border-white/30 flex items-center gap-4 mb-8">
<span className="text-4xl font-black">+{pointsEarned}</span>
<span className="font-bold uppercase opacity-80">Points</span>
</div>
) : (
<div className="text-2xl font-bold opacity-90 mb-8 max-w-md text-center">
Don't worry, you can catch up in the next round!
</div>
)}
{/* Streak Indicator */}
{streak > 1 && isCorrect && (
<div className="flex items-center gap-2 text-yellow-200 font-black text-2xl animate-pulse">
<Flame fill="currentColor" />
<span>Answer Streak: {streak}</span>
</div>
)}
<div className="mt-8 bg-black/20 px-6 py-2 rounded-xl text-xl font-bold">
Total Score: {newScore}
</div>
</motion.div>
{!isCorrect && (
<motion.div
initial={{ y: 100 }}
animate={{ y: 0 }}
transition={{ delay: 0.5, type: 'spring' }}
className="absolute bottom-0 left-0 right-0 bg-black/30 backdrop-blur-md p-6 pb-12"
>
<p className="text-center text-sm font-bold uppercase tracking-widest mb-4 opacity-70">The correct answer was</p>
<div className="flex items-center justify-center gap-4">
<div className={`${COLORS[correctOption.color]} p-3 rounded-xl shadow-lg`}>
<ShapeIcon size={24} fill="currentColor" />
</div>
<span className="text-2xl font-black">{correctOption.text}</span>
</div>
</motion.div>
)}
</motion.div>
</div>
);
};

74
components/Scoreboard.tsx Normal file
View file

@ -0,0 +1,74 @@
import React from 'react';
import { Player } from '../types';
import { motion } from 'framer-motion';
import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, LabelList } from 'recharts';
import { Loader2 } from 'lucide-react';
interface ScoreboardProps {
players: Player[];
onNext: () => void;
isHost: boolean;
}
export const Scoreboard: React.FC<ScoreboardProps> = ({ players, onNext, isHost }) => {
const sortedPlayers = [...players].sort((a, b) => b.score - a.score).slice(0, 5);
return (
<div className="flex flex-col h-screen p-8">
<header className="text-center mb-12">
<h1 className="text-5xl font-black text-white font-display drop-shadow-md">Scoreboard</h1>
</header>
<div className="flex-1 bg-white rounded-[3rem] shadow-[0_20px_0_rgba(0,0,0,0.2)] p-12 flex flex-col text-gray-900 max-w-6xl w-full mx-auto relative z-10 border-8 border-white/50">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={sortedPlayers}
layout="vertical"
margin={{ top: 20, right: 120, left: 20, bottom: 5 }}
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 24, fontWeight: 800, fill: '#333', fontFamily: 'Fredoka' }}
width={200}
tickLine={false}
axisLine={false}
/>
<Bar dataKey="score" radius={[0, 20, 20, 0]} barSize={50} animationDuration={1500}>
{sortedPlayers.map((entry, index) => (
<Cell
key={`cell-${index}`}
fill={entry.id.startsWith('host') ? '#46178f' : '#8884d8'}
className="filter drop-shadow-md"
/>
))}
<LabelList
dataKey="score"
position="right"
offset={15}
style={{ fontSize: '24px', fontWeight: '900', fill: '#46178f', fontFamily: 'Fredoka' }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
<div className="mt-8 flex justify-end max-w-6xl w-full mx-auto">
{isHost ? (
<button
onClick={onNext}
className="bg-white text-[#46178f] px-12 py-4 rounded-2xl text-2xl font-black shadow-[0_8px_0_rgba(0,0,0,0.2)] hover:scale-105 active:shadow-none active:translate-y-[8px] transition-all"
>
Next
</button>
) : (
<div className="flex items-center gap-3 bg-white/10 px-8 py-4 rounded-2xl backdrop-blur-md border-2 border-white/20">
<Loader2 className="animate-spin w-8 h-8" />
<span className="text-xl font-bold">Waiting for host...</span>
</div>
)}
</div>
</div>
);
};

25
constants.ts Normal file
View file

@ -0,0 +1,25 @@
import { Triangle, Diamond, Circle, Square } from 'lucide-react';
export const COLORS = {
red: 'bg-red-600',
blue: 'bg-blue-600',
yellow: 'bg-yellow-600',
green: 'bg-green-600',
purple: 'bg-[#46178f]',
purpleLight: 'bg-[#864cbf]',
};
export const SHAPES = {
triangle: Triangle,
diamond: Diamond,
circle: Circle,
square: Square,
};
export const BOT_NAMES = [
"QuizWiz", "FastFinger", "Brainiac", "KnowItAll", "Guesser",
"LuckyStrike", "OwlFan", "Kahootie", "ZigZag", "Pixel"
];
export const QUESTION_TIME = 20; // seconds
export const POINTS_PER_QUESTION = 1000;

356
hooks/useGame.ts Normal file
View file

@ -0,0 +1,356 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Quiz, Player, GameState, GameRole, NetworkMessage, AnswerOption, Question } from '../types';
import { generateQuiz } from '../services/geminiService';
import { POINTS_PER_QUESTION, QUESTION_TIME } from '../constants';
import { Peer, DataConnection } from 'peerjs';
export const useGame = () => {
const [role, setRole] = useState<GameRole>('HOST');
const [gameState, setGameState] = useState<GameState>('LANDING');
const [quiz, setQuiz] = useState<Quiz | null>(null);
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [players, setPlayers] = useState<Player[]>([]);
const [timeLeft, setTimeLeft] = useState(0);
const [error, setError] = useState<string | null>(null);
const [hasAnswered, setHasAnswered] = useState(false);
const [gamePin, setGamePin] = useState<string | null>(null);
const [currentCorrectShape, setCurrentCorrectShape] = useState<string | null>(null);
const [lastPointsEarned, setLastPointsEarned] = useState<number | null>(null);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const peerRef = useRef<Peer | null>(null);
const connectionsRef = useRef<Map<string, DataConnection>>(new Map());
const hostConnectionRef = useRef<DataConnection | null>(null);
// Refs for callbacks/async functions to access latest state
const timeLeftRef = useRef(0);
const playersRef = useRef<Player[]>([]);
const currentQuestionIndexRef = useRef(0);
const quizRef = useRef<Quiz | null>(null);
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
useEffect(() => { playersRef.current = players; }, [players]);
useEffect(() => { currentQuestionIndexRef.current = currentQuestionIndex; }, [currentQuestionIndex]);
useEffect(() => { quizRef.current = quiz; }, [quiz]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
// -- HOST LOGIC --
const startQuizGen = async (topic: string) => {
try {
setGameState('GENERATING');
setError(null);
setRole('HOST');
const generatedQuiz = await generateQuiz(topic);
initializeHostGame(generatedQuiz);
} catch (e) {
setError("Failed to generate quiz.");
setGameState('LANDING');
}
};
const startManualCreation = () => {
setRole('HOST');
setGameState('CREATING');
};
const finalizeManualQuiz = (manualQuiz: Quiz) => {
initializeHostGame(manualQuiz);
};
// We use a ref to hold the current handleHostData function
// This prevents stale closures in the PeerJS event listeners
const handleHostDataRef = useRef<(conn: DataConnection, data: NetworkMessage) => void>(() => {});
const initializeHostGame = (newQuiz: Quiz) => {
setQuiz(newQuiz);
const pin = generateGamePin();
setGamePin(pin);
const peer = new Peer(`openhoot-${pin}`);
peerRef.current = peer;
peer.on('open', (id) => {
const hostPlayer: Player = {
id: 'host',
name: 'Host (You)',
score: 0,
streak: 0,
lastAnswerCorrect: null,
isBot: false,
avatarSeed: Math.random()
};
setPlayers([hostPlayer]);
setGameState('LOBBY');
});
peer.on('connection', (conn) => {
conn.on('data', (data: any) => {
// Delegate to the ref to ensure we always use the latest function closure
handleHostDataRef.current(conn, data);
});
});
peer.on('error', () => {
setError("Network error. Try a different topic or reload.");
setGameState('LANDING');
});
};
const handleHostData = (conn: DataConnection, data: NetworkMessage) => {
if (data.type === 'JOIN') {
const newPlayer: Player = {
id: conn.peer,
name: data.payload.name,
score: 0,
streak: 0,
lastAnswerCorrect: null,
isBot: false,
avatarSeed: Math.random()
};
setPlayers(prev => {
if (prev.find(p => p.id === newPlayer.id)) return prev;
return [...prev, newPlayer];
});
connectionsRef.current.set(conn.peer, conn);
conn.send({ type: 'WELCOME', payload: { playerId: conn.peer, quizTitle: 'OpenHoot', players: [] } });
}
if (data.type === 'ANSWER') {
const { playerId, isCorrect } = data.payload;
const currentPlayer = playersRef.current.find(p => p.id === playerId);
if (!currentPlayer || currentPlayer.lastAnswerCorrect !== null) return;
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0;
const newScore = currentPlayer.score + points;
setPlayers(prev => prev.map(p => {
if (p.id !== playerId) return p;
return { ...p, score: newScore, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
}));
conn.send({ type: 'RESULT', payload: { isCorrect, scoreAdded: points, newScore } });
}
};
// Update the ref whenever handleHostData changes (which happens on render)
useEffect(() => {
handleHostDataRef.current = handleHostData;
});
const broadcast = (msg: NetworkMessage) => {
connectionsRef.current.forEach(conn => { if (conn.open) conn.send(msg); });
};
const startHostGame = () => {
setCurrentQuestionIndex(0);
broadcast({ type: 'GAME_START', payload: {} });
startCountdown();
};
const startCountdown = () => {
setGameState('COUNTDOWN');
broadcast({ type: 'START_COUNTDOWN', payload: { duration: 3 } });
let count = 3;
setTimeLeft(count);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
count--;
setTimeLeft(count);
if (count <= 0) {
if (timerRef.current) clearInterval(timerRef.current);
startQuestion();
}
}, 1000);
};
const startQuestion = () => {
setGameState('QUESTION');
setHasAnswered(false);
setLastPointsEarned(null);
setTimeLeft(QUESTION_TIME);
setPlayers(prev => prev.map(p => ({ ...p, lastAnswerCorrect: null })));
// Use refs to get the latest state inside this async callback
const currentQuiz = quizRef.current;
const currentIndex = currentQuestionIndexRef.current;
if (currentQuiz) {
const currentQ = currentQuiz.questions[currentIndex];
// Ensure options exist
const options = currentQ.options || [];
const correctOpt = options.find(o => o.isCorrect);
const correctShape = correctOpt?.shape || 'triangle';
setCurrentCorrectShape(correctShape);
const optionsForClient = options.map(o => ({
...o,
isCorrect: false // Masked
}));
broadcast({
type: 'QUESTION_START',
payload: {
totalQuestions: currentQuiz.questions.length,
currentQuestionIndex: currentIndex,
timeLimit: QUESTION_TIME,
correctShape,
questionText: currentQ.text,
options: optionsForClient
}
});
}
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) { endQuestion(); return 0; }
return prev - 1;
});
}, 1000);
};
const endQuestion = () => {
if (timerRef.current) clearInterval(timerRef.current);
setGameState('REVEAL');
broadcast({ type: 'TIME_UP', payload: {} });
setTimeout(() => setGameState('SCOREBOARD'), 4000);
};
const nextQuestion = () => {
const currentQuiz = quizRef.current;
const currentIndex = currentQuestionIndexRef.current;
if (!currentQuiz) return;
if (currentIndex < currentQuiz.questions.length - 1) {
setCurrentQuestionIndex(prev => prev + 1);
setTimeout(() => startCountdown(), 0);
} else {
setGameState('PODIUM');
broadcast({ type: 'GAME_OVER', payload: { players } });
}
};
// -- CLIENT LOGIC --
const joinGame = (pin: string, name: string) => {
setRole('CLIENT');
setError(null);
setGamePin(pin);
const peer = new Peer();
peerRef.current = peer;
peer.on('open', () => {
const conn = peer.connect(`openhoot-${pin}`);
hostConnectionRef.current = conn;
conn.on('open', () => {
conn.send({ type: 'JOIN', payload: { name } });
setGameState('LOBBY');
});
conn.on('data', (data: any) => handleClientData(data));
conn.on('close', () => { setError("Disconnected"); setGameState('LANDING'); });
setTimeout(() => { if (!conn.open) setError("Check PIN"); }, 5000);
});
};
const handleClientData = (data: NetworkMessage) => {
if (data.type === 'WELCOME') setQuiz({ title: data.payload.quizTitle, questions: [] });
if (data.type === 'START_COUNTDOWN') {
setGameState('COUNTDOWN');
setTimeLeft(data.payload.duration);
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1));
}, 1000);
}
if (data.type === 'QUESTION_START') {
setGameState('QUESTION');
setHasAnswered(false);
setLastPointsEarned(null);
setCurrentQuestionIndex(data.payload.currentQuestionIndex);
setTimeLeft(data.payload.timeLimit);
setCurrentCorrectShape(data.payload.correctShape);
setQuiz(prev => {
const tempQuestions = prev ? [...prev.questions] : [];
while (tempQuestions.length < data.payload.totalQuestions) {
tempQuestions.push({ id: `loading-${tempQuestions.length}`, text: '', options: [], timeLimit: 0 });
}
tempQuestions[data.payload.currentQuestionIndex] = {
id: `q-${data.payload.currentQuestionIndex}`,
text: data.payload.questionText,
options: data.payload.options,
timeLimit: data.payload.timeLimit
};
return { title: prev?.title || 'Quiz', questions: tempQuestions };
});
if (timerRef.current) clearInterval(timerRef.current);
timerRef.current = setInterval(() => setTimeLeft(prev => Math.max(0, prev - 1)), 1000);
}
if (data.type === 'RESULT') {
setLastPointsEarned(data.payload.scoreAdded);
}
if (data.type === 'TIME_UP') {
setGameState('REVEAL');
}
if (data.type === 'SHOW_SCOREBOARD') {
setGameState('SCOREBOARD');
setPlayers(data.payload.players);
}
if (data.type === 'GAME_OVER') {
setGameState('PODIUM');
setPlayers(data.payload.players);
}
};
const handleAnswer = (arg: boolean | AnswerOption) => {
if (hasAnswered || gameState !== 'QUESTION') return;
setHasAnswered(true);
if (role === 'HOST') {
const isCorrect = arg as boolean;
// Use Ref for time to be consistent with handleHostData logic
const points = isCorrect ? Math.round(POINTS_PER_QUESTION * (timeLeftRef.current / QUESTION_TIME)) : 0;
setLastPointsEarned(points);
setPlayers(prev => prev.map(p => {
if (p.id !== 'host') return p;
return { ...p, score: p.score + points, streak: isCorrect ? p.streak + 1 : 0, lastAnswerCorrect: isCorrect };
}));
} else {
const option = arg as AnswerOption;
const isCorrect = option.shape === currentCorrectShape;
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect } });
}
};
useEffect(() => {
if (role === 'HOST' && (gameState === 'SCOREBOARD' || gameState === 'PODIUM')) {
broadcast({ type: gameState === 'SCOREBOARD' ? 'SHOW_SCOREBOARD' : 'GAME_OVER', payload: { players } });
}
}, [gameState, players, role]);
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (peerRef.current) peerRef.current.destroy();
};
}, []);
return {
role, gameState, quiz, players, currentQuestionIndex, timeLeft, error, gamePin, hasAnswered, lastPointsEarned, currentCorrectShape,
startQuizGen, startManualCreation, finalizeManualQuiz, joinGame, startGame: startHostGame, handleAnswer, nextQuestion
};
};

62
index.html Normal file
View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenHoot</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300;400;600;700&family=Montserrat:wght@400;700;900&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Montserrat', sans-serif;
/* Richer purple gradient */
background: linear-gradient(180deg, #46178f 0%, #25094f 100%);
color: white;
overflow-x: hidden;
min-height: 100vh;
}
h1, h2, h3, h4, h5, h6, .font-display {
font-family: 'Fredoka', sans-serif;
}
/* Whimsical Background Animation */
@keyframes floatUp {
0% { transform: translateY(100vh) rotate(0deg); opacity: 0; }
10% { opacity: 0.3; }
90% { opacity: 0.3; }
100% { transform: translateY(-10vh) rotate(360deg); opacity: 0; }
}
.floating-shape {
position: fixed;
z-index: 0;
pointer-events: none;
animation-name: floatUp;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
</style>
<script type="importmap">
{
"imports": {
"react/": "https://esm.sh/react@^19.2.3/",
"react": "https://esm.sh/react@^19.2.3",
"@google/genai": "https://esm.sh/@google/genai@^1.35.0",
"lucide-react": "https://esm.sh/lucide-react@^0.562.0",
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
"uuid": "https://esm.sh/uuid@^13.0.0",
"recharts": "https://esm.sh/recharts@^3.6.0",
"framer-motion": "https://esm.sh/framer-motion@^12.26.1",
"canvas-confetti": "https://esm.sh/canvas-confetti@^1.9.4",
"peerjs": "https://esm.sh/peerjs@^1.5.2"
}
}
</script>
<link rel="stylesheet" href="/index.css">
</head>
<body>
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
</html>

15
index.tsx Normal file
View file

@ -0,0 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error("Could not find root element to mount to");
}
const root = ReactDOM.createRoot(rootElement);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

5
metadata.json Normal file
View file

@ -0,0 +1,5 @@
{
"name": "Kaboot",
"description": "An open-source, AI-powered Kahoot clone. Generate quizzes on any topic instantly using Gemini and play against AI bots in a single-player arcade mode.",
"requestFramePermissions": []
}

3183
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "kaboot",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.3",
"@google/genai": "^1.35.0",
"lucide-react": "^0.562.0",
"react-dom": "^19.2.3",
"uuid": "^13.0.0",
"recharts": "^3.6.0",
"framer-motion": "^12.26.1",
"canvas-confetti": "^1.9.4",
"peerjs": "^1.5.2"
},
"devDependencies": {
"@types/node": "^22.14.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

86
services/geminiService.ts Normal file
View file

@ -0,0 +1,86 @@
import { GoogleGenAI, Type } from "@google/genai";
import { Quiz, Question, AnswerOption } from "../types";
import { v4 as uuidv4 } from 'uuid';
const getClient = () => {
const apiKey = process.env.API_KEY;
if (!apiKey) {
throw new Error("API_KEY environment variable is missing");
}
return new GoogleGenAI({ apiKey });
};
export const generateQuiz = async (topic: string): Promise<Quiz> => {
const ai = getClient();
const prompt = `Generate a trivia quiz about "${topic}". Create 10 engaging multiple-choice questions. Each question must have exactly 4 options, and exactly one correct answer. Vary the difficulty.`;
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING, description: "A catchy title for the quiz" },
questions: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING, description: "The question text" },
options: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
text: { type: Type.STRING },
isCorrect: { type: Type.BOOLEAN }
},
required: ["text", "isCorrect"]
},
}
},
required: ["text", "options"]
}
}
},
required: ["title", "questions"]
}
}
});
if (!response.text) {
throw new Error("Failed to generate quiz content");
}
const data = JSON.parse(response.text);
// Transform to our internal type with shapes/colors pre-assigned
const questions: Question[] = data.questions.map((q: any) => {
const shapes = ['triangle', 'diamond', 'circle', 'square'] as const;
const colors = ['red', 'blue', 'yellow', 'green'] as const;
// Shuffle options so the correct one isn't always first (though Gemini usually randomizes, safety first)
// Actually, to map shapes consistently, let's keep array order but assign props
const options: AnswerOption[] = q.options.map((opt: any, index: number) => ({
text: opt.text,
isCorrect: opt.isCorrect,
shape: shapes[index % 4],
color: colors[index % 4]
}));
return {
id: uuidv4(),
text: q.text,
options: options,
timeLimit: 20
};
});
return {
title: data.title,
questions
};
};

34
tasks.md Normal file
View file

@ -0,0 +1,34 @@
# OpenHoot Development Task List
## Core Setup
- [x] Initialize project structure (React, TypeScript, Vite-like setup)
- [x] Configure Tailwind CSS via CDN
- [x] Define global types (Quiz, Question, Player, GameState)
- [x] Create constant definitions for Kahoot-style colors and shapes
## Backend Services (Gemini AI)
- [x] Setup Google GenAI client
- [x] Implement `generateQuiz` function with strictly typed JSON schema
- [x] Error handling for API limits or failures
## Game Logic (Hooks)
- [x] Create `useGame` hook to manage state machine (Landing -> Lobby -> Game -> Podium)
- [x] Implement "Bot" logic (simulated players joining and answering)
- [x] Implement Timer logic for questions
- [x] Implement Scoring system (speed + accuracy)
## UI Components
- [x] **Landing Page**: Topic input and "Generate" button
- [x] **Lobby**: Display Game PIN and simulated player list
- [x] **Game Interface**:
- [x] Question display with countdown
- [x] Answer buttons (Red/Triangle, Blue/Diamond, etc.)
- [x] "Host View" vs "Player View" hybrid layout
- [x] **Scoreboard**: Bar charts showing round results
- [x] **Podium**: Final winners display with confetti feel
## Polish & UX
- [x] Add animations using Framer Motion (transitions, entrances)
- [x] Ensure responsive design (Mobile-first)
- [x] Add sound effects/music toggle (Visual only for this demo)
- [x] Final code cleanup and type safety checks

29
tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"types": [
"node"
],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

65
types.ts Normal file
View file

@ -0,0 +1,65 @@
export type GameState =
| 'LANDING'
| 'CREATING'
| 'GENERATING'
| 'LOBBY'
| 'COUNTDOWN'
| 'QUESTION'
| 'REVEAL'
| 'SCOREBOARD'
| 'PODIUM';
export type GameRole = 'HOST' | 'CLIENT';
export interface AnswerOption {
text: string;
isCorrect: boolean;
shape: 'triangle' | 'diamond' | 'circle' | 'square';
color: 'red' | 'blue' | 'yellow' | 'green';
}
export interface Question {
id: string;
text: string;
options: AnswerOption[];
timeLimit: number; // in seconds
}
export interface Quiz {
title: string;
questions: Question[];
}
export interface Player {
id: string;
name: string;
score: number;
streak: number;
lastAnswerCorrect: boolean | null;
isBot: boolean;
avatarSeed: number;
}
// Network Types
export type NetworkMessage =
| { type: 'JOIN'; payload: { name: string } }
| { type: 'WELCOME'; payload: { playerId: string; quizTitle: string; players: Player[] } }
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
| { type: 'GAME_START'; payload: {} }
| { type: 'START_COUNTDOWN'; payload: { duration: number } }
| {
type: 'QUESTION_START';
payload: {
totalQuestions: number;
currentQuestionIndex: number;
timeLimit: number;
correctShape: string;
questionText: string;
options: AnswerOption[];
}
}
| { type: 'ANSWER'; payload: { playerId: string; isCorrect: boolean } }
| { type: 'RESULT'; payload: { isCorrect: boolean; scoreAdded: number; newScore: number } }
| { type: 'TIME_UP'; payload: {} }
| { type: 'SHOW_SCOREBOARD'; payload: { players: Player[] } }
| { type: 'GAME_OVER'; payload: { players: Player[] } };

23
vite.config.ts Normal file
View file

@ -0,0 +1,23 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
return {
server: {
port: 3000,
host: '0.0.0.0',
},
plugins: [react()],
define: {
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
}
}
};
});