Quiz Game
Quiz Game
Cloudflare Partner Tech Hub
https://hub.cf-lab.org/quiz
const adminI18n = { 'en': { playersJoined: 'players joined', startGame: 'Start Game', loading: 'Loading...', manageQuizzes: 'Manage Quizzes', gameHistory: 'Game History', selectQuiz: 'Please select a quiz first', noQuestions: 'This quiz has no questions. Please add some first.', loadFailed: 'Failed to load questions', answered: 'Answered', correctAnswer: 'Correct Answer', next: 'Next', finalRankings: 'Final Rankings', congratulations: 'Congratulations!', leaderboard: 'Leaderboard', saveResults: 'Save Results', saving: 'Saving...', saved: 'Saved!', saveFailed: 'Save failed', restart: 'Restart', resetGame: 'Reset Game', clearPlayers: 'Clear All Players', back: 'Back', backToLobby: 'Back to Lobby', backToQuizList: 'Back to Quiz List', quizManagement: 'Quiz Management', newQuizPlaceholder: 'New quiz name...', create: 'Create', history: 'Game History', editQuestions: 'Edit Questions', question: 'Question', optionLabel: 'Option', correctAnswerLabel: 'Correct Answer', category: 'Category (optional)', addQuestion: 'Add Question', noQuizzesYet: 'No quizzes yet', noQuestionsYet: 'No questions yet', questions: 'questions', }, 'zh-CN': { playersJoined: '人已加入', startGame: '开始游戏', loading: '加载中...', manageQuizzes: '管理题库', gameHistory: '游戏记录', selectQuiz: '请先选择一个题库', noQuestions: '这个题库没有题目,请先添加。', loadFailed: '加载题目失败', answered: '已答', correctAnswer: '正确答案', next: '下一题', finalRankings: '最终排名', congratulations: '恭喜!', leaderboard: '排行榜', saveResults: '保存结果', saving: '保存中...', saved: '已保存!', saveFailed: '保存失败', restart: '重新开始', resetGame: '重置游戏', clearPlayers: '清除所有玩家', back: '返回', backToLobby: '返回大厅', backToQuizList: '返回题库列表', quizManagement: '题库管理', newQuizPlaceholder: '新题库名称...', create: '创建', history: '游戏记录', editQuestions: '编辑题目', question: '题目', optionLabel: '选项', correctAnswerLabel: '正确答案', category: '分类(可选)', addQuestion: '添加题目', noQuizzesYet: '还没有题库', noQuestionsYet: '还没有题目', questions: '题', }, 'zh-TW': { playersJoined: '人已加入', startGame: '開始遊戲', loading: '載入中...', manageQuizzes: '管理題庫', gameHistory: '遊戲記錄', selectQuiz: '請先選擇一個題庫', noQuestions: '這個題庫沒有題目,請先新增。', loadFailed: '載入題目失敗', answered: '已答', correctAnswer: '正確答案', next: '下一題', finalRankings: '最終排名', congratulations: '恭喜!', leaderboard: '排行榜', saveResults: '儲存結果', saving: '儲存中...', saved: '已儲存!', saveFailed: '儲存失敗', restart: '重新開始', resetGame: '重置遊戲', clearPlayers: '清除所有玩家', back: '返回', backToLobby: '返回大廳', backToQuizList: '返回題庫列表', quizManagement: '題庫管理', newQuizPlaceholder: '新題庫名稱...', create: '建立', history: '遊戲記錄', editQuestions: '編輯題目', question: '題目', optionLabel: '選項', correctAnswerLabel: '正確答案', category: '分類(可選)', addQuestion: '新增題目', noQuizzesYet: '還沒有題庫', noQuestionsYet: '還沒有題目', questions: '題', } };
// Question translations for known Cloudflare fun facts const questionTranslations = { 'en': {}, 'zh-CN': { 'Cloudflare uses a wall of ___ in its San Francisco office to generate cryptographic random numbers.': 'Cloudflare 在旧金山办公室使用一面 ___ 墙来生成加密随机数。', 'Lava Lamps': '熔岩灯', 'Dice Machines': '骰子机', 'Pendulums': '摆锤', 'Fish Tanks': '鱼缸', 'On what date was Cloudflare's famous 1.1.1.1 DNS resolver launched?': 'Cloudflare 著名的 1.1.1.1 DNS 解析器是哪天发布的?', 'January 1, 2018': '2018年1月1日', 'April 1, 2018': '2018年4月1日', 'July 4, 2018': '2018年7月4日', 'November 11, 2018': '2018年11月11日', 'Approximately what percentage of all websites on the Internet use Cloudflare?': '全球大约有多少比例的网站使用 Cloudflare?', 'Cloudflare's global network spans data centers in how many cities worldwide?': 'Cloudflare 的全球网络覆盖了多少个城市的数据中心?', 'What is the closest estimate of Cloudflare's total network DDoS mitigation capacity?': 'Cloudflare 网络DDoS防护总带宽最接近以下哪个数值?', }, 'zh-TW': { 'Cloudflare uses a wall of ___ in its San Francisco office to generate cryptographic random numbers.': 'Cloudflare 在舊金山辦公室使用一面 ___ 牆來產生加密隨機數。', 'Lava Lamps': '熔岩燈', 'Dice Machines': '骰子機', 'Pendulums': '擺錘', 'Fish Tanks': '魚缸', 'On what date was Cloudflare's famous 1.1.1.1 DNS resolver launched?': 'Cloudflare 著名的 1.1.1.1 DNS 解析器是哪天發布的?', 'January 1, 2018': '2018年1月1日', 'April 1, 2018': '2018年4月1日', 'July 4, 2018': '2018年7月4日', 'November 11, 2018': '2018年11月11日', 'Approximately what percentage of all websites on the Internet use Cloudflare?': '全球大約有多少比例的網站使用 Cloudflare?', 'Cloudflare's global network spans data centers in how many cities worldwide?': 'Cloudflare 的全球網路覆蓋了多少個城市的資料中心?', 'What is the closest estimate of Cloudflare's total network DDoS mitigation capacity?': 'Cloudflare 網路 DDoS 防護總頻寬最接近以下哪個數值?', } };
function at(key) { return adminI18n[currentLang][key] || adminI18n['en'][key] || key; }
function translateText(text) { if (currentLang === 'en') return text; const map = questionTranslations[currentLang] || {}; return map[text] || text; }
function setLang(lang) { currentLang = lang; document.querySelectorAll('.lang-btn').forEach(b => { b.classList.toggle('active', b.dataset.lang === lang); }); // Lobby document.querySelector('#lobby .player-label').textContent = at('playersJoined'); document.getElementById('startBtn').textContent = at('startGame'); document.getElementById('clearPlayersBtn').textContent = at('clearPlayers'); document.querySelector('[onclick="showManage()"]').textContent = at('manageQuizzes'); document.querySelector('[onclick="showHistory()"]').textContent = at('gameHistory'); // Reveal & Finished document.getElementById('correctAnswerTitle').textContent = at('correctAnswer'); document.querySelector('#revealView [onclick="resetGame()"]').textContent = at('resetGame'); document.getElementById('finalTitle').textContent = at('finalRankings'); document.getElementById('congratsText').textContent = at('congratulations'); document.getElementById('saveBtn').textContent = at('saveResults'); document.querySelector('#finishedView [onclick="resetGame()"]').textContent = at('restart'); // History & Manage document.querySelector('#historySection .btn-back').textContent = at('back'); document.querySelector('#historySection h2').textContent = at('history'); document.querySelector('#manageSection .btn-back').textContent = at('backToLobby'); document.querySelector('#manageSection h2').textContent = at('quizManagement'); document.getElementById('newQuizName').placeholder = at('newQuizPlaceholder'); document.querySelector('#manageSection .m-add-row button').textContent = at('create'); // Editor document.querySelector('#questionEditor .btn-back').textContent = at('backToQuizList'); }
// Connect WebSocket immediately (auth via cookie) connectWebSocket(); loadQuizList();
function connectWebSocket() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(proto + '//' + location.host + '/api/quiz/ws?role=admin');
ws.onopen = () => { generateQR(); loadQuizList(); };
ws.onerror = () => { console.error('WebSocket connection failed'); };
ws.onclose = (e) => { if (e.code === 4001) { document.body.innerHTML = '
ws.onmessage = (event) => { const msg = JSON.parse(event.data); handleMessage(msg); }; }
function generateQR() { const url = location.origin + '/quiz'; document.getElementById('playUrl').textContent = url; try { const qr = qrcode(0, 'M'); qr.addData(url); qr.make(); document.getElementById('qrcode').innerHTML = qr.createSvgTag(6, 0); } catch(e) { console.error('QR error:', e); } }
function handleMessage(msg) { switch (msg.type) { case 'state': updateState(msg); break; case 'player_joined': case 'player_left': updatePlayers(msg); break; case 'question': showQuestion(msg); break; case 'answer_progress': updateProgress(msg); break; case 'reveal': showReveal(msg); break; case 'finished': showFinished(msg); break; case 'reset': lastPlayerCount = msg.playerCount || 0; document.getElementById('playerCount').textContent = lastPlayerCount; renderPlayerChips(msg.players || []); showLobby(); break; } }
function updateState(msg) { document.getElementById('playerCount').textContent = msg.playerCount; renderPlayerChips(msg.players || []); if (msg.state === 'finished' && msg.leaderboard) { showFinished(msg); return; } // Don't force lobby if user is managing quizzes/questions/history if (msg.state === 'lobby') { const m = document.getElementById('manageSection'); const e = document.getElementById('questionEditor'); const h = document.getElementById('historySection'); if (m.style.display !== 'block' && e.style.display !== 'block' && h.style.display !== 'block') { showLobby(); } } }
function updatePlayers(msg) { document.getElementById('playerCount').textContent = msg.playerCount; lastPlayerCount = msg.playerCount; if (msg.players) renderPlayerChips(msg.players); document.getElementById('startBtn').disabled = msg.playerCount < 1; document.getElementById('clearPlayersBtn').style.display = msg.playerCount > 0 ? '' : 'none'; }
function renderPlayerChips(players) { document.getElementById('playerList').innerHTML = players.map(p => '' + escapeHtml(p.nickname) + '' ).join(''); document.getElementById('startBtn').disabled = players.length < 1; document.getElementById('clearPlayersBtn').style.display = players.length > 0 ? '' : 'none'; }
function showLobby() { hideAll(); document.getElementById('lobby').style.display = 'block'; if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } document.getElementById('startBtn').textContent = at('startGame'); document.getElementById('startBtn').disabled = lastPlayerCount < 1; loadQuizList(); }
function startGame() { const quizId = document.getElementById('quizSelect').value; if (!quizId) { alert(at('selectQuiz')); return; } document.getElementById('startBtn').disabled = true; document.getElementById('startBtn').textContent = at('loading'); fetch('/api/quiz/quizzes/' + quizId + '/questions') .then(r => r.json()) .then(data => { if (!data.success || !data.data.length) { alert(at('noQuestions')); document.getElementById('startBtn').disabled = false; document.getElementById('startBtn').textContent = at('startGame'); return; } const questions = data.data.map(q => ({ id: q.id, question: translateText(q.question), options: JSON.parse(q.options).map(o => translateText(o)), answer: q.answer, category: q.category, })); ws.send(JSON.stringify({ type: 'start_game', questions })); }) .catch(() => { alert(at('loadFailed')); document.getElementById('startBtn').disabled = false; document.getElementById('startBtn').textContent = at('startGame'); }); }
function showQuestion(msg) { hideAll(); document.getElementById('questionView').style.display = 'block'; document.getElementById('qNumber').textContent = 'Q ' + (msg.index + 1) + ' / ' + msg.total; document.getElementById('qAnswered').textContent = 'Answered: 0'; document.getElementById('qText').textContent = msg.question;
const colors = ['opt-a', 'opt-b', 'opt-c', 'opt-d']; const letters = ['A', 'B', 'C', 'D']; document.getElementById('optionsGrid').innerHTML = msg.options.map((opt, i) => '
let remaining = Math.ceil(msg.remaining / 1000); const total = msg.duration / 1000; document.getElementById('timerText').textContent = remaining; document.getElementById('timerText').className = 'timer-text'; document.getElementById('timerFill').style.width = (remaining / total * 100) + '%';
if (timerInterval) clearInterval(timerInterval); timerInterval = setInterval(() => { remaining--; if (remaining <= 0) { remaining = 0; clearInterval(timerInterval); } document.getElementById('timerText').textContent = remaining; document.getElementById('timerFill').style.width = (remaining / total * 100) + '%'; if (remaining <= 10) document.getElementById('timerText').className = 'timer-text warning'; }, 1000); }
function updateProgress(msg) { document.getElementById('qAnswered').textContent = at('answered') + ': ' + msg.answered + ' / ' + msg.total; }
function showReveal(msg) { hideAll(); if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } document.getElementById('revealView').style.display = 'block';
const colors = ['opt-a', 'opt-b', 'opt-c', 'opt-d']; const letters = ['A', 'B', 'C', 'D']; document.getElementById('revealQuestion').textContent = msg.question || '';
document.getElementById('revealOptions').innerHTML = msg.stats.map((count, i) => { const isCorrect = i === msg.correctAnswer; const label = msg.options ? msg.options[i] : ''; return '
if (msg.isLast) { document.getElementById('leaderboard').innerHTML = '
🏆 ' + at('finalRankings') + '...
'; } else { renderLeaderboard('leaderboard', msg.leaderboard); } document.getElementById('nextBtn').textContent = msg.isLast ? at('finalRankings') : at('next'); }function showFinished(msg) { hideAll(); document.getElementById('finishedView').style.display = 'block'; renderLeaderboard('finalLeaderboard', msg.leaderboard); lastLeaderboard = msg.leaderboard; document.getElementById('saveBtn').disabled = false; document.getElementById('saveBtn').textContent = at('saveResults'); launchConfetti(); }
function renderLeaderboard(containerId, lb) { document.getElementById(containerId).innerHTML = '
' + at('leaderboard') + '
' + lb.map((p, i) => { var rank = i < 3 ? ['\uD83E\uDD47', '\uD83E\uDD48', '\uD83E\uDD49'][i] : String(i + 1); var cls = i < 3 ? ['gold', 'silver', 'bronze'][i] : ''; return 'function nextQuestion() { ws.send(JSON.stringify({ type: 'next_question' })); }
function resetGame() { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'reset' })); } else { connectWebSocket(); } }
function saveGame() { if (!lastLeaderboard) return; const btn = document.getElementById('saveBtn'); btn.disabled = true; btn.textContent = at('saving'); fetch('/api/quiz/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ results: lastLeaderboard, playerCount: lastPlayerCount }), }) .then(r => r.json()) .then(data => { btn.textContent = data.success ? at('saved') : at('saveFailed'); }) .catch(() => { btn.textContent = at('saveFailed'); btn.disabled = false; }); }
function showHistory() { hideAll(); document.getElementById('historySection').style.display = 'block'; document.getElementById('historyList').innerHTML = '
Loading...
'; fetch('/api/quiz/history') .then(r => r.json()) .then(data => { if (!data.success || !data.data.length) { document.getElementById('historyList').innerHTML = 'No game history
'; return; } const rankClass = ['gold', 'silver', 'bronze', '', '']; const medals = ['\uD83E\uDD47', '\uD83E\uDD48', '\uD83E\uDD49', '4', '5']; document.getElementById('historyList').innerHTML = data.data.map(game => { const results = JSON.parse(game.results); return 'Load failed
'; }); }function deleteHistory(id, btn) { if (!confirm('Delete this record?')) return; fetch('/api/quiz/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }) .then(r => r.json()) .then(data => { if (data.success) btn.closest('.history-card').remove(); }); }
function backFromHistory() { hideAll(); document.getElementById('lobby').style.display = 'block'; }
function hideAll() { document.getElementById('lobby').style.display = 'none'; document.getElementById('questionView').style.display = 'none'; document.getElementById('revealView').style.display = 'none'; document.getElementById('finishedView').style.display = 'none'; document.getElementById('historySection').style.display = 'none'; document.getElementById('manageSection').style.display = 'none'; document.getElementById('questionEditor').style.display = 'none'; }
// ========== Quiz list for lobby selector ========== function loadQuizList() { fetch('/api/quiz/quizzes') .then(r => r.json()) .then(data => { allQuizzes = data.success ? data.data : []; const sel = document.getElementById('quizSelect'); const prev = sel.value; sel.innerHTML = allQuizzes.length === 0 ? '' : allQuizzes.map(q => '').join(''); if (prev && sel.querySelector('option[value="' + prev + '"]')) sel.value = prev; }).catch(() => {}); }
// ========== Quiz Management ========== function showManage() { hideAll(); document.getElementById('manageSection').style.display = 'block'; loadManageQuizList(); }
function backFromManage() { hideAll(); document.getElementById('lobby').style.display = 'block'; loadQuizList(); }
function loadManageQuizList() { fetch('/api/quiz/quizzes') .then(r => r.json()) .then(data => { if (!data.success || !data.data.length) { document.getElementById('quizList').innerHTML = '
No quizzes yet
'; return; } document.getElementById('quizList').innerHTML = data.data.map(q => '<div class="manage-row" data-id="' + q.id + '" data-name="' + escapeHtml(q.name).replace(/"/g, '"') + '" this.dataset.name)">' + '' + escapeHtml(q.name) + '' + '' + q.question_count + ' questions' + 'function createQuiz() { const input = document.getElementById('newQuizName'); const name = input.value.trim(); if (!name) return; fetch('/api/quiz/quizzes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }) .then(r => r.json()) .then(data => { if (data.success) { input.value = ''; loadManageQuizList(); } else { alert('Create failed: ' + (data.error || 'Unknown error')); } }) .catch(e => { alert('Network error: ' + e.message); }); }
function renameQuiz(id) { const name = prompt('Enter new name:'); if (!name) return; fetch('/api/quiz/quizzes/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }), }) .then(r => r.json()) .then(data => { if (data.success) loadManageQuizList(); }); }
function deleteQuiz(id) { if (!confirm('Delete this quiz and all its questions?')) return; fetch('/api/quiz/quizzes/' + id, { method: 'DELETE', }) .then(r => r.json()) .then(data => { if (data.success) loadManageQuizList(); }); }
// ========== Question Editor ========== function openQuizEditor(quizId, quizName) { currentEditQuizId = quizId; currentEditQuizName = quizName; hideAll(); document.getElementById('questionEditor').style.display = 'block'; document.getElementById('editorTitle').textContent = quizName + ' — Questions'; renderAddForm(); loadQuestions(); }
function backFromEditor() { currentEditQuizId = null; showManage(); }
function renderAddForm() { const letters = ['A', 'B', 'C', 'D']; document.getElementById('addQuestionForm').innerHTML = '
function loadQuestions() { fetch('/api/quiz/quizzes/' + currentEditQuizId + '/questions') .then(r => r.json()) .then(data => { if (!data.success || !data.data.length) { document.getElementById('questionList').innerHTML = '
No questions yet
'; return; } const letters = ['A', 'B', 'C', 'D']; document.getElementById('questionList').innerHTML = data.data.map((q, idx) => { const opts = JSON.parse(q.options); return 'function addQuestion() { const question = document.getElementById('nqText').value.trim(); const options = [0,1,2,3].map(i => document.getElementById('nqOpt' + i).value.trim()); const answer = parseInt(document.getElementById('nqAnswer').value); const category = document.getElementById('nqCat').value.trim(); if (!question || options.some(o => !o)) { alert('Please fill in the question and all options'); return; } fetch('/api/quiz/quizzes/' + currentEditQuizId + '/questions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, options, answer, category }), }) .then(r => r.json()) .then(data => { if (data.success) { document.getElementById('nqText').value = ''; [0,1,2,3].forEach(i => document.getElementById('nqOpt' + i).value = ''); document.getElementById('nqCat').value = ''; loadQuestions(); } }); }
function editQuestion(qId) { fetch('/api/quiz/quizzes/' + currentEditQuizId + '/questions') .then(r => r.json()) .then(data => { const q = data.data.find(x => x.id === qId); if (!q) return; const opts = JSON.parse(q.options); const letters = ['A', 'B', 'C', 'D']; const formHtml = '
function saveEditQuestion(qId, sortOrder) { const question = document.getElementById('eqText_' + qId).value.trim(); const options = [0,1,2,3].map(i => document.getElementById('eqOpt' + i + '' + qId).value.trim()); const answer = parseInt(document.getElementById('eqAnswer' + qId).value); const category = document.getElementById('eqCat_' + qId).value.trim(); if (!question || options.some(o => !o)) { alert('Please fill in all fields'); return; } fetch('/api/quiz/questions/' + qId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ question, options, answer, category, sort_order: sortOrder }), }) .then(r => r.json()) .then(data => { if (data.success) loadQuestions(); }); }
function deleteQuestion(qId) { if (!confirm('Delete this question?')) return; fetch('/api/quiz/questions/' + qId, { method: 'DELETE', }) .then(r => r.json()) .then(data => { if (data.success) loadQuestions(); }); }
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function launchConfetti() { const canvas = document.getElementById('confettiCanvas'); if (!canvas) return; const ctx = canvas.getContext('2d'); canvas.width = window.innerWidth; canvas.height = window.innerHeight; const pieces = []; const colors = ['#f97316','#eab308','#22c55e','#3b82f6','#ef4444','#a855f7']; for (let i = 0; i < 150; i++) { pieces.push({ x: Math.random() * canvas.width, y: Math.random() * canvas.height - canvas.height, w: Math.random() * 10 + 5, h: Math.random() * 6 + 3, color: colors[Math.floor(Math.random() * colors.length)], vy: Math.random() * 3 + 2, vx: (Math.random() - .5) * 2, rot: Math.random() * 360, vr: (Math.random() - .5) * 10, }); } let frame = 0; function draw() { ctx.clearRect(0, 0, canvas.width, canvas.height); pieces.forEach(p => { p.x += p.vx; p.y += p.vy; p.rot += p.vr; ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(p.rot * Math.PI / 180); ctx.fillStyle = p.color; ctx.fillRect(-p.w/2, -p.h/2, p.w, p.h); ctx.restore(); }); frame++; if (frame < 300) requestAnimationFrame(draw); else ctx.clearRect(0, 0, canvas.width, canvas.height); } draw(); }