+
{
const hostSecretRef = useRef(null);
const gameStateRef = useRef("LANDING");
const firstCorrectPlayerIdRef = useRef(null);
+ const currentCorrectShapeRef = useRef(null);
useEffect(() => { timeLeftRef.current = timeLeft; }, [timeLeft]);
useEffect(() => { playersRef.current = players; }, [players]);
@@ -83,9 +85,19 @@ export const useGame = () => {
useEffect(() => { hostSecretRef.current = hostSecret; }, [hostSecret]);
useEffect(() => { gameStateRef.current = gameState; }, [gameState]);
useEffect(() => { firstCorrectPlayerIdRef.current = firstCorrectPlayerId; }, [firstCorrectPlayerId]);
+ useEffect(() => { currentCorrectShapeRef.current = currentCorrectShape; }, [currentCorrectShape]);
const generateGamePin = () => Math.floor(Math.random() * 900000) + 100000 + "";
+ const generateRandomName = (): string => {
+ return uniqueNamesGenerator({
+ dictionaries: [adjectives, animals],
+ separator: ' ',
+ style: 'capital',
+ length: 2,
+ });
+ };
+
const syncGameState = useCallback(async () => {
if (!gamePinRef.current || !hostSecretRef.current) return;
if (gameStateRef.current === 'LANDING' || gameStateRef.current === 'EDITING' || gameStateRef.current === 'CREATING' || gameStateRef.current === 'GENERATING') return;
@@ -510,13 +522,23 @@ export const useGame = () => {
allPlayers: playersRef.current.map(p => ({ id: p.id, name: p.name, lastAnswerCorrect: p.lastAnswerCorrect }))
});
+ let assignedName = payload.name;
+ if (!reconnectedPlayer && gameConfigRef.current.randomNamesEnabled) {
+ assignedName = generateRandomName();
+ }
+
+ let updatedPlayers = playersRef.current;
+ let newPlayer: Player | null = null;
+
if (reconnectedPlayer) {
- setPlayers(prev => prev.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p));
+ updatedPlayers = playersRef.current.map(p => p.id === reconnectedPlayer.id ? { ...p, id: conn.peer } : p);
+ setPlayers(updatedPlayers);
+ assignedName = reconnectedPlayer.name;
} else if (!playersRef.current.find(p => p.id === conn.peer)) {
const colorIndex = playersRef.current.length % PLAYER_COLORS.length;
- const newPlayer: Player = {
+ newPlayer = {
id: conn.peer,
- name: payload.name,
+ name: assignedName,
score: 0,
previousScore: 0,
streak: 0,
@@ -527,7 +549,8 @@ export const useGame = () => {
avatarSeed: Math.random(),
color: PLAYER_COLORS[colorIndex]
};
- setPlayers(prev => [...prev, newPlayer]);
+ updatedPlayers = [...playersRef.current, newPlayer];
+ setPlayers(updatedPlayers);
}
const currentState = gameStateRef.current;
@@ -538,7 +561,7 @@ export const useGame = () => {
const welcomePayload: any = {
playerId: conn.peer,
quizTitle: currentQuiz?.title || 'Kaboot',
- players: playersRef.current,
+ players: updatedPlayers,
gameState: currentState,
currentQuestionIndex: currentIndex,
totalQuestions: currentQuiz?.questions.length || 0,
@@ -548,6 +571,7 @@ export const useGame = () => {
hasAnswered: false,
lastAnswerCorrect: null,
selectedShape: null,
+ assignedName,
};
if (currentQuestion) {
@@ -726,6 +750,48 @@ export const useGame = () => {
const endQuestion = () => {
if (timerRef.current) clearInterval(timerRef.current);
+
+ const unansweredPlayers = playersRef.current.filter(p => p.lastAnswerCorrect === null);
+ if (unansweredPlayers.length > 0) {
+ const updatedPlayers = playersRef.current.map(p => {
+ if (p.lastAnswerCorrect !== null) return p;
+
+ const playerRank = getPlayerRank(p.id, playersRef.current);
+ const breakdown = calculatePointsWithBreakdown({
+ isCorrect: false,
+ timeLeftMs: 0,
+ questionTimeMs: QUESTION_TIME_MS,
+ streak: 0,
+ playerRank,
+ isFirstCorrect: false,
+ config: gameConfigRef.current,
+ });
+ const newScore = Math.max(0, p.score + breakdown.total);
+
+ if (p.id === 'host') {
+ setLastPointsEarned(breakdown.total);
+ setLastAnswerCorrect(false);
+ setCurrentPlayerScore(newScore);
+ setCurrentStreak(0);
+ } else {
+ const conn = connectionsRef.current.get(p.id);
+ if (conn?.open) {
+ conn.send({ type: 'RESULT', payload: { isCorrect: false, scoreAdded: breakdown.total, newScore, breakdown } });
+ }
+ }
+
+ return {
+ ...p,
+ score: newScore,
+ previousScore: p.score,
+ streak: 0,
+ lastAnswerCorrect: false,
+ pointsBreakdown: breakdown,
+ };
+ });
+ setPlayers(updatedPlayers);
+ }
+
setGameState('REVEAL');
broadcast({ type: 'TIME_UP', payload: {} });
};
@@ -915,6 +981,13 @@ export const useGame = () => {
if (payload.players) {
setPlayers(payload.players);
}
+ if (payload.assignedName) {
+ setCurrentPlayerName(payload.assignedName);
+ const session = getStoredSession();
+ if (session) {
+ storeSession({ ...session, playerName: payload.assignedName });
+ }
+ }
if (payload.selectedShape && payload.options) {
const matchedOption = payload.options.find(opt => opt.shape === payload.selectedShape);
if (matchedOption) {
@@ -1087,8 +1160,9 @@ export const useGame = () => {
} else {
const option = arg as AnswerOption;
setSelectedOption(option);
- const isCorrect = option.shape === currentCorrectShape;
- console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape, isCorrect });
+ // Use ref to avoid stale closure - currentCorrectShape state may not be updated yet
+ const isCorrect = option.shape === currentCorrectShapeRef.current;
+ console.log('[CLIENT] Answering:', { selectedShape: option.shape, currentCorrectShape: currentCorrectShapeRef.current, isCorrect });
hostConnectionRef.current?.send({ type: 'ANSWER', payload: { playerId: peerRef.current?.id, isCorrect, selectedShape: option.shape } });
}
};
diff --git a/hooks/useUserPreferences.ts b/hooks/useUserPreferences.ts
new file mode 100644
index 0000000..af188b6
--- /dev/null
+++ b/hooks/useUserPreferences.ts
@@ -0,0 +1,94 @@
+import { useState, useCallback, useEffect } from 'react';
+import toast from 'react-hot-toast';
+import { useAuthenticatedFetch } from './useAuthenticatedFetch';
+import type { UserPreferences } from '../types';
+import { COLOR_SCHEMES } from '../types';
+
+const DEFAULT_PREFERENCES: UserPreferences = {
+ colorScheme: 'blue',
+};
+
+export const applyColorScheme = (schemeId: string) => {
+ const scheme = COLOR_SCHEMES.find(s => s.id === schemeId) || COLOR_SCHEMES[0];
+ document.documentElement.style.setProperty('--theme-primary', scheme.primary);
+ document.documentElement.style.setProperty('--theme-primary-dark', scheme.primaryDark);
+ document.documentElement.style.setProperty('--theme-primary-darker', scheme.primaryDarker);
+};
+
+interface UseUserPreferencesReturn {
+ preferences: UserPreferences;
+ hasAIAccess: boolean;
+ loading: boolean;
+ saving: boolean;
+ fetchPreferences: () => Promise;
+ savePreferences: (prefs: UserPreferences) => Promise;
+ applyColorScheme: (schemeId: string) => void;
+}
+
+export const useUserPreferences = (): UseUserPreferencesReturn => {
+ const { authFetch, isAuthenticated } = useAuthenticatedFetch();
+ const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES);
+ const [hasAIAccess, setHasAIAccess] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+
+ const fetchPreferences = useCallback(async () => {
+ if (!isAuthenticated) return;
+
+ setLoading(true);
+ try {
+ const response = await authFetch('/api/users/me/preferences');
+ if (response.ok) {
+ const data = await response.json();
+ const prefs: UserPreferences = {
+ colorScheme: data.colorScheme || 'blue',
+ geminiApiKey: data.geminiApiKey || undefined,
+ };
+ setPreferences(prefs);
+ setHasAIAccess(data.hasAIAccess || false);
+ applyColorScheme(prefs.colorScheme);
+ }
+ } catch {
+ } finally {
+ setLoading(false);
+ }
+ }, [authFetch, isAuthenticated]);
+
+ const savePreferences = useCallback(async (prefs: UserPreferences) => {
+ setSaving(true);
+ try {
+ const response = await authFetch('/api/users/me/preferences', {
+ method: 'PUT',
+ body: JSON.stringify(prefs),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to save preferences');
+ }
+
+ setPreferences(prefs);
+ applyColorScheme(prefs.colorScheme);
+ toast.success('Preferences saved!');
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'Failed to save preferences';
+ toast.error(message);
+ throw err;
+ } finally {
+ setSaving(false);
+ }
+ }, [authFetch]);
+
+ useEffect(() => {
+ fetchPreferences();
+ }, [fetchPreferences]);
+
+ return {
+ preferences,
+ hasAIAccess,
+ loading,
+ saving,
+ fetchPreferences,
+ savePreferences,
+ applyColorScheme,
+ };
+};
diff --git a/index.css b/index.css
index e8b64b7..1d21d0e 100644
--- a/index.css
+++ b/index.css
@@ -1,16 +1,11 @@
-html, body, #root {
- height: 100%;
- min-height: 100dvh;
-}
-
.h-screen {
- height: 100dvh;
+ height: 100dvh !important;
}
.min-h-screen {
- min-height: 100dvh;
+ min-height: 100dvh !important;
}
.max-h-screen {
- max-height: 100dvh;
+ max-height: 100dvh !important;
}
diff --git a/index.html b/index.html
index 46b0b5c..925b482 100644
--- a/index.html
+++ b/index.html
@@ -33,8 +33,19 @@
font-family: 'Montserrat', sans-serif;
background: linear-gradient(180deg, var(--theme-primary) 0%, var(--theme-primary-darker) 100%);
color: white;
- overflow-x: hidden;
- min-height: 100dvh;
+ overflow: hidden;
+ height: 100dvh;
+ margin: 0;
+ padding: 0;
+ }
+
+ html {
+ height: 100dvh;
+ overflow: hidden;
+ }
+
+ #root {
+ height: 100%;
}
h1, h2, h3, h4, h5, h6, .font-display {
diff --git a/package-lock.json b/package-lock.json
index 7323a05..6c215ed 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -22,6 +22,7 @@
"react-hot-toast": "^2.6.0",
"react-oidc-context": "^3.2.0",
"recharts": "^3.6.0",
+ "unique-names-generator": "^4.7.1",
"uuid": "^13.0.0"
},
"devDependencies": {
@@ -4160,6 +4161,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/unique-names-generator": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
+ "integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
diff --git a/package.json b/package.json
index 72eb488..1077f01 100644
--- a/package.json
+++ b/package.json
@@ -26,6 +26,7 @@
"react-hot-toast": "^2.6.0",
"react-oidc-context": "^3.2.0",
"recharts": "^3.6.0",
+ "unique-names-generator": "^4.7.1",
"uuid": "^13.0.0"
},
"devDependencies": {
diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts
index 8f654e3..ce70dfd 100644
--- a/server/src/db/connection.ts
+++ b/server/src/db/connection.ts
@@ -60,6 +60,18 @@ const runMigrations = () => {
db.exec("ALTER TABLE game_sessions ADD COLUMN first_correct_player_id TEXT");
console.log("Migration: Added first_correct_player_id to game_sessions");
}
+
+ const userTableInfo2 = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
+ const hasColorScheme = userTableInfo2.some(col => col.name === "color_scheme");
+ if (!hasColorScheme) {
+ db.exec("ALTER TABLE users ADD COLUMN color_scheme TEXT DEFAULT 'blue'");
+ console.log("Migration: Added color_scheme to users");
+ }
+ const hasGeminiKey = userTableInfo2.some(col => col.name === "gemini_api_key");
+ if (!hasGeminiKey) {
+ db.exec("ALTER TABLE users ADD COLUMN gemini_api_key TEXT");
+ console.log("Migration: Added gemini_api_key to users");
+ }
};
runMigrations();
diff --git a/server/src/db/schema.sql b/server/src/db/schema.sql
index 86b3d49..f1e2cd6 100644
--- a/server/src/db/schema.sql
+++ b/server/src/db/schema.sql
@@ -5,7 +5,9 @@ CREATE TABLE IF NOT EXISTS users (
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
- default_game_config TEXT
+ default_game_config TEXT,
+ color_scheme TEXT DEFAULT 'blue',
+ gemini_api_key TEXT
);
CREATE TABLE IF NOT EXISTS quizzes (
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
index a5aa677..bbf6c14 100644
--- a/server/src/middleware/auth.ts
+++ b/server/src/middleware/auth.ts
@@ -34,6 +34,7 @@ export interface AuthenticatedUser {
preferred_username: string;
email?: string;
name?: string;
+ groups?: string[];
}
export interface AuthenticatedRequest extends Request {
@@ -74,6 +75,7 @@ export function requireAuth(
preferred_username: payload.preferred_username || payload.sub!,
email: payload.email,
name: payload.name,
+ groups: payload.groups || [],
};
next();
diff --git a/server/src/routes/games.ts b/server/src/routes/games.ts
index c083f1e..0d6aa29 100644
--- a/server/src/routes/games.ts
+++ b/server/src/routes/games.ts
@@ -74,6 +74,7 @@ router.get('/:pin', (req: Request, res: Response) => {
return;
}
+ const gameConfig = JSON.parse(session.game_config);
res.json({
pin: session.pin,
hostPeerId: session.host_peer_id,
@@ -81,6 +82,7 @@ router.get('/:pin', (req: Request, res: Response) => {
currentQuestionIndex: session.current_question_index,
quizTitle: JSON.parse(session.quiz_data).title,
playerCount: JSON.parse(session.players_data).length,
+ randomNamesEnabled: gameConfig.randomNamesEnabled || false,
});
} catch (err) {
console.error('Error getting game session:', err);
diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts
index 24925b5..0860ea7 100644
--- a/server/src/routes/users.ts
+++ b/server/src/routes/users.ts
@@ -8,11 +8,16 @@ router.use(requireAuth);
router.get('/me', (req: AuthenticatedRequest, res: Response) => {
const user = db.prepare(`
- SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig, created_at as createdAt, last_login as lastLogin
+ SELECT id, username, email, display_name as displayName, default_game_config as defaultGameConfig,
+ color_scheme as colorScheme, gemini_api_key as geminiApiKey,
+ created_at as createdAt, last_login as lastLogin
FROM users
WHERE id = ?
`).get(req.user!.sub) as Record | undefined;
+ const groups = req.user!.groups || [];
+ const hasAIAccess = groups.includes('kaboot-ai-access');
+
if (!user) {
res.json({
id: req.user!.sub,
@@ -20,6 +25,9 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
email: req.user!.email,
displayName: req.user!.name,
defaultGameConfig: null,
+ colorScheme: 'blue',
+ geminiApiKey: null,
+ hasAIAccess,
createdAt: null,
lastLogin: null,
isNew: true,
@@ -36,7 +44,12 @@ router.get('/me', (req: AuthenticatedRequest, res: Response) => {
}
}
- res.json({ ...user, defaultGameConfig: parsedConfig, isNew: false });
+ res.json({
+ ...user,
+ defaultGameConfig: parsedConfig,
+ hasAIAccess,
+ isNew: false
+ });
});
router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
@@ -64,4 +77,47 @@ router.put('/me/default-config', (req: AuthenticatedRequest, res: Response) => {
res.json({ success: true });
});
+router.get('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
+ const user = db.prepare(`
+ SELECT color_scheme as colorScheme, gemini_api_key as geminiApiKey
+ FROM users
+ WHERE id = ?
+ `).get(req.user!.sub) as { colorScheme: string | null; geminiApiKey: string | null } | undefined;
+
+ const groups = req.user!.groups || [];
+ const hasAIAccess = groups.includes('kaboot-ai-access');
+
+ res.json({
+ colorScheme: user?.colorScheme || 'blue',
+ geminiApiKey: user?.geminiApiKey || null,
+ hasAIAccess,
+ });
+});
+
+router.put('/me/preferences', (req: AuthenticatedRequest, res: Response) => {
+ const { colorScheme, geminiApiKey } = req.body;
+
+ const upsertUser = db.prepare(`
+ INSERT INTO users (id, username, email, display_name, color_scheme, gemini_api_key, last_login)
+ VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
+ ON CONFLICT(id) DO UPDATE SET
+ color_scheme = ?,
+ gemini_api_key = ?,
+ last_login = CURRENT_TIMESTAMP
+ `);
+
+ upsertUser.run(
+ req.user!.sub,
+ req.user!.preferred_username,
+ req.user!.email || null,
+ req.user!.name || null,
+ colorScheme || 'blue',
+ geminiApiKey || null,
+ colorScheme || 'blue',
+ geminiApiKey || null
+ );
+
+ res.json({ success: true });
+});
+
export default router;
diff --git a/types.ts b/types.ts
index 26378a7..6b85014 100644
--- a/types.ts
+++ b/types.ts
@@ -13,6 +13,34 @@ export type GameState =
| 'WAITING_TO_REJOIN'
| 'HOST_RECONNECTED';
+export interface ColorScheme {
+ id: string;
+ name: string;
+ primary: string;
+ primaryDark: string;
+ primaryDarker: string;
+}
+
+export const COLOR_SCHEMES: ColorScheme[] = [
+ { id: 'blue', name: 'Ocean Blue', primary: '#2563eb', primaryDark: '#1e40af', primaryDarker: '#1e3a5f' },
+ { id: 'purple', name: 'Royal Purple', primary: '#7c3aed', primaryDark: '#5b21b6', primaryDarker: '#3b1a5f' },
+ { id: 'pink', name: 'Hot Pink', primary: '#db2777', primaryDark: '#9d174d', primaryDarker: '#5f1a3b' },
+ { id: 'red', name: 'Ruby Red', primary: '#dc2626', primaryDark: '#991b1b', primaryDarker: '#5f1a1a' },
+ { id: 'orange', name: 'Sunset Orange', primary: '#ea580c', primaryDark: '#c2410c', primaryDarker: '#5f2a1a' },
+ { id: 'yellow', name: 'Golden Yellow', primary: '#ca8a04', primaryDark: '#a16207', primaryDarker: '#5f4a1a' },
+ { id: 'green', name: 'Emerald Green', primary: '#16a34a', primaryDark: '#15803d', primaryDarker: '#1a5f3b' },
+ { id: 'teal', name: 'Teal', primary: '#0d9488', primaryDark: '#0f766e', primaryDarker: '#1a5f5a' },
+ { id: 'cyan', name: 'Cyan', primary: '#0891b2', primaryDark: '#0e7490', primaryDarker: '#1a4a5f' },
+ { id: 'indigo', name: 'Indigo', primary: '#4f46e5', primaryDark: '#4338ca', primaryDarker: '#2a2a5f' },
+ { id: 'slate', name: 'Slate', primary: '#475569', primaryDark: '#334155', primaryDarker: '#1e293b' },
+ { id: 'rose', name: 'Rose', primary: '#e11d48', primaryDark: '#be123c', primaryDarker: '#5f1a2a' },
+];
+
+export interface UserPreferences {
+ colorScheme: string;
+ geminiApiKey?: string;
+}
+
export type GameRole = 'HOST' | 'CLIENT';
export interface AnswerOption {
@@ -34,6 +62,7 @@ export interface GameConfig {
shuffleQuestions: boolean;
shuffleAnswers: boolean;
hostParticipates: boolean;
+ randomNamesEnabled: boolean;
streakBonusEnabled: boolean;
streakThreshold: number;
streakMultiplier: number;
@@ -49,6 +78,7 @@ export const DEFAULT_GAME_CONFIG: GameConfig = {
shuffleQuestions: false,
shuffleAnswers: false,
hostParticipates: true,
+ randomNamesEnabled: false,
streakBonusEnabled: false,
streakThreshold: 3,
streakMultiplier: 1.1,
@@ -142,6 +172,7 @@ export type NetworkMessage =
options?: AnswerOption[];
correctShape?: string;
timeLeft?: number;
+ assignedName?: string;
} }
| { type: 'PLAYER_JOINED'; payload: { player: Player } }
| { type: 'GAME_START'; payload: {} }