Add Pi Trivia Blitz, Projectile Pi games + expand math questions

Torey Heinz committed Mar 14, 2026
commit 3d4756e6693d4fa4fa0cad231dcb5f88dc4aa176
Showing 9 changed files with 821 additions and 64 deletions
assets/js/app.js +5 -3
@@ @@ -38,9 +38,11 @@ const AVATAR_COLORS = {
}
const STATIONS = [
- { x: 150, y: 150, label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: "#06b6d4" },
- { x: 650, y: 150, label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: "#8b5cf6" },
- { x: 400, y: 450, label: "Slice\nthe Pi", icon: "\u{1FA93}", color: "#f59e0b" },
+ { x: 130, y: 130, label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: "#06b6d4" },
+ { x: 670, y: 130, label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: "#8b5cf6" },
+ { x: 130, y: 460, label: "Slice\nthe Pi", icon: "\u{1FA93}", color: "#f59e0b" },
+ { x: 670, y: 460, label: "Pi Trivia\nBlitz", icon: "\u{1F4A1}", color: "#ec4899" },
+ { x: 400, y: 300, label: "Projectile\nPi", icon: "\u{1F680}", color: "#22c55e" },
]
const SpectateHub = {
assets/js/game.js +11 -1
@@ @@ -4,6 +4,8 @@ import { HubScene } from "./game/HubScene"
import { PiMemoryGame } from "./game/PiMemoryGame"
import { MonteCarloGame } from "./game/MonteCarloGame"
import { SliceThePiGame } from "./game/SliceThePiGame"
+ import { TriviaGame } from "./game/TriviaGame"
+ import { ProjectileGame } from "./game/ProjectileGame"
import { SoundFX } from "./game/SoundFX"
// --- Phoenix Socket Connection ---
@@ @@ -80,6 +82,12 @@ window.piStation = {
case "slice_the_pi":
SliceThePiGame.start(content, channel)
break
+ case "pi_trivia":
+ TriviaGame.start(content, channel)
+ break
+ case "projectile_pi":
+ ProjectileGame.start(content, channel)
+ break
}
window.piStation._currentChannel = channel
@@ @@ -112,8 +120,10 @@ window.piStation = {
// Station prompt โ€” called from HubScene
showStationPrompt(station) {
const el = document.getElementById("station-prompt")
+ const isMobile = "ontouchstart" in window
if (station) {
- el.textContent = `Tap to play ${station.label.replace("\n", " ")}!`
+ const action = isMobile ? "Tap" : "Press SPACE"
+ el.textContent = `${action} to play ${station.label.replace("\n", " ")}!`
el.classList.add("visible")
el.onclick = () => window.piStation.openMiniGame(station.game)
} else {
assets/js/game/HubScene.js +13 -3
@@ @@ -14,9 +14,11 @@ const AVATAR_COLORS = {
}
const STATIONS = [
- { x: 150, y: 150, game: "pi_memory", label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: 0x06b6d4 },
- { x: 650, y: 150, game: "monte_carlo", label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: 0x8b5cf6 },
- { x: 400, y: 450, game: "slice_the_pi", label: "Slice\nthe Pi", icon: "\u{1FA93}", color: 0xf59e0b },
+ { x: 130, y: 130, game: "pi_memory", label: "Pi Memory\nSprint", icon: "\u{1F9E0}", color: 0x06b6d4 },
+ { x: 670, y: 130, game: "monte_carlo", label: "Monte Carlo\nPi", icon: "\u{1F3AF}", color: 0x8b5cf6 },
+ { x: 130, y: 460, game: "slice_the_pi", label: "Slice\nthe Pi", icon: "\u{1FA93}", color: 0xf59e0b },
+ { x: 670, y: 460, game: "pi_trivia", label: "Pi Trivia\nBlitz", icon: "\u{1F4A1}", color: 0xec4899 },
+ { x: 400, y: 300, game: "projectile_pi", label: "Projectile\nPi", icon: "\u{1F680}", color: 0x22c55e },
]
export class HubScene extends Phaser.Scene {
@@ @@ -198,6 +200,14 @@ export class HubScene extends Phaser.Scene {
right: Phaser.Input.Keyboard.KeyCodes.D,
})
+ // Space to enter nearby station
+ this.input.keyboard.on("keydown-SPACE", () => {
+ if (document.activeElement === document.getElementById("chat-input")) return
+ if (this.nearStation) {
+ window.piStation.openMiniGame(this.nearStation.game)
+ }
+ })
+
// Mobile joystick
if ("ontouchstart" in window) {
const joystickZone = document.createElement("div")
assets/js/game/ProjectileGame.js +334 -0
@@ @@ -0,0 +1,334 @@
+ const GRAVITY = 9.81
+ const MAX_ATTEMPTS = 5
+ const CANVAS_W = 450
+ const CANVAS_H = 250
+ const SCALE = 4 // pixels per meter
+
+ export const ProjectileGame = {
+ start(container, channel) {
+ while (container.firstChild) container.removeChild(container.firstChild)
+
+ let attempts = 0
+ let bestError = Infinity
+ let bestScore = 0
+ let targetX = 0
+ let angle = 45
+ let velocity = 20
+ let animating = false
+ let totalScore = 0
+ let round = 0
+ const TOTAL_ROUNDS = 3
+
+ const title = document.createElement("div")
+ title.className = "mg-title"
+ title.textContent = "Projectile Pi"
+
+ const subtitle = document.createElement("div")
+ subtitle.className = "mg-subtitle"
+ subtitle.textContent = "Launch to hit the target! Adjust angle and velocity."
+
+ const canvas = document.createElement("canvas")
+ canvas.width = CANVAS_W
+ canvas.height = CANVAS_H
+ canvas.style.cssText = "border-radius:0.75rem;border:2px solid rgba(255,255,255,0.2);max-width:100%;background:#0f0a2e;"
+ const ctx = canvas.getContext("2d")
+
+ const statsEl = document.createElement("div")
+ statsEl.style.cssText = "color:#a78bfa;font-size:0.85rem;margin:0.5rem 0;min-height:1.5rem;"
+
+ // Angle control
+ const angleRow = createSlider("Angle", 5, 85, 45, "\u00B0", (v) => { angle = v; drawScene() })
+ // Velocity control
+ const velRow = createSlider("Velocity", 5, 50, 20, " m/s", (v) => { velocity = v; drawScene() })
+
+ const launchBtn = document.createElement("button")
+ launchBtn.className = "mg-btn"
+ launchBtn.textContent = "Launch!"
+ launchBtn.addEventListener("click", () => launch())
+
+ const feedbackEl = document.createElement("div")
+ feedbackEl.style.cssText = "min-height: 2rem; margin: 0.5rem 0; font-size: 1rem;"
+
+ const resultArea = document.createElement("div")
+ resultArea.style.cssText = "margin-top: 0.5rem;"
+
+ container.appendChild(title)
+ container.appendChild(subtitle)
+ container.appendChild(canvas)
+ container.appendChild(statsEl)
+ container.appendChild(angleRow.container)
+ container.appendChild(velRow.container)
+ container.appendChild(launchBtn)
+ container.appendChild(feedbackEl)
+ container.appendChild(resultArea)
+
+ function startRound() {
+ round++
+ attempts = 0
+ bestError = Infinity
+ bestScore = 0
+ feedbackEl.textContent = ""
+ launchBtn.style.display = ""
+
+ channel.push("projectile_get_target", {}).receive("ok", (data) => {
+ targetX = data.target_x
+ statsEl.textContent = `Round ${round}/${TOTAL_ROUNDS} | Target: ${targetX}m (\u2248 ${(targetX / Math.PI).toFixed(0)}\u03C0) | Attempts: ${attempts}/${MAX_ATTEMPTS}`
+ drawScene()
+ })
+ }
+
+ function drawScene(trail) {
+ ctx.clearRect(0, 0, CANVAS_W, CANVAS_H)
+
+ // Sky gradient
+ const grad = ctx.createLinearGradient(0, 0, 0, CANVAS_H)
+ grad.addColorStop(0, "#0a0a2e")
+ grad.addColorStop(1, "#1a1040")
+ ctx.fillStyle = grad
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H)
+
+ // Ground
+ ctx.fillStyle = "#1a3a1a"
+ ctx.fillRect(0, CANVAS_H - 20, CANVAS_W, 20)
+
+ // Grid markers
+ ctx.font = "9px monospace"
+ ctx.fillStyle = "#555"
+ ctx.textAlign = "center"
+ for (let m = 20; m <= 120; m += 20) {
+ const px = m * SCALE
+ if (px < CANVAS_W) {
+ ctx.fillText(`${m}m`, px, CANVAS_H - 5)
+ ctx.strokeStyle = "rgba(255,255,255,0.05)"
+ ctx.beginPath()
+ ctx.moveTo(px, 0)
+ ctx.lineTo(px, CANVAS_H - 20)
+ ctx.stroke()
+ }
+ }
+
+ // Target
+ if (targetX > 0) {
+ const tx = targetX * SCALE
+ ctx.fillStyle = "#ef4444"
+ ctx.fillRect(tx - 3, CANVAS_H - 35, 6, 15)
+ // Flag
+ ctx.fillStyle = "#ef4444"
+ ctx.beginPath()
+ ctx.moveTo(tx + 3, CANVAS_H - 35)
+ ctx.lineTo(tx + 18, CANVAS_H - 28)
+ ctx.lineTo(tx + 3, CANVAS_H - 21)
+ ctx.fill()
+ // Label
+ ctx.font = "10px monospace"
+ ctx.fillStyle = "#ef4444"
+ ctx.textAlign = "center"
+ ctx.fillText(`${targetX}m`, tx, CANVAS_H - 40)
+ }
+
+ // Launcher
+ const launchX = 15
+ const launchY = CANVAS_H - 20
+ ctx.save()
+ ctx.translate(launchX, launchY)
+ ctx.rotate(-angle * Math.PI / 180)
+ ctx.fillStyle = "#06b6d4"
+ ctx.fillRect(0, -3, 20, 6)
+ ctx.restore()
+ ctx.beginPath()
+ ctx.arc(launchX, launchY, 6, 0, Math.PI * 2)
+ ctx.fillStyle = "#06b6d4"
+ ctx.fill()
+
+ // Trajectory preview (dotted)
+ ctx.setLineDash([3, 4])
+ ctx.strokeStyle = "rgba(6, 182, 212, 0.3)"
+ ctx.beginPath()
+ const vx = velocity * Math.cos(angle * Math.PI / 180)
+ const vy = velocity * Math.sin(angle * Math.PI / 180)
+ for (let t = 0; t < 8; t += 0.1) {
+ const px = launchX + vx * t * SCALE
+ const py = launchY - (vy * t - 0.5 * GRAVITY * t * t) * SCALE
+ if (t === 0) ctx.moveTo(px, py)
+ else ctx.lineTo(px, py)
+ if (py > launchY) break
+ }
+ ctx.stroke()
+ ctx.setLineDash([])
+
+ // Draw trails from previous attempts
+ if (trail) {
+ ctx.strokeStyle = "rgba(139, 92, 246, 0.6)"
+ ctx.lineWidth = 2
+ ctx.beginPath()
+ trail.forEach((p, i) => {
+ if (i === 0) ctx.moveTo(p.x, p.y)
+ else ctx.lineTo(p.x, p.y)
+ })
+ ctx.stroke()
+ ctx.lineWidth = 1
+
+ // Impact point
+ const last = trail[trail.length - 1]
+ ctx.beginPath()
+ ctx.arc(last.x, last.y, 4, 0, Math.PI * 2)
+ ctx.fillStyle = "#8b5cf6"
+ ctx.fill()
+ }
+ }
+
+ function launch() {
+ if (animating || attempts >= MAX_ATTEMPTS) return
+ animating = true
+ attempts++
+ if (window.SoundFX) window.SoundFX.dart()
+
+ const launchX = 15
+ const launchY = CANVAS_H - 20
+ const vx = velocity * Math.cos(angle * Math.PI / 180)
+ const vy = velocity * Math.sin(angle * Math.PI / 180)
+
+ const trail = []
+ let t = 0
+ const dt = 0.03
+
+ function animate() {
+ t += dt
+ const px = launchX + vx * t * SCALE
+ const py = launchY - (vy * t - 0.5 * GRAVITY * t * t) * SCALE
+ trail.push({ x: px, y: py })
+
+ drawScene(trail)
+
+ // Draw projectile
+ ctx.beginPath()
+ ctx.arc(px, py, 5, 0, Math.PI * 2)
+ ctx.fillStyle = "#f59e0b"
+ ctx.fill()
+ ctx.strokeStyle = "#fff"
+ ctx.lineWidth = 1
+ ctx.stroke()
+
+ if (py < launchY && px < CANVAS_W) {
+ requestAnimationFrame(animate)
+ } else {
+ // Landed
+ animating = false
+ const landingM = (px - launchX) / SCALE
+ const error = Math.abs(landingM - targetX)
+ const pts = Math.max(0, Math.round(500 - error * 50))
+
+ if (error < bestError) {
+ bestError = error
+ bestScore = pts
+ }
+
+ statsEl.textContent = `Round ${round}/${TOTAL_ROUNDS} | Target: ${targetX}m | Attempts: ${attempts}/${MAX_ATTEMPTS} | Landing: ${landingM.toFixed(1)}m`
+
+ if (error < 1) {
+ feedbackEl.style.color = "#22c55e"
+ feedbackEl.textContent = `Bullseye! ${landingM.toFixed(1)}m (error: ${error.toFixed(2)}m) +${pts}pts`
+ if (window.SoundFX) window.SoundFX.milestone()
+ } else if (error < 5) {
+ feedbackEl.style.color = "#f59e0b"
+ feedbackEl.textContent = `Close! ${landingM.toFixed(1)}m (error: ${error.toFixed(2)}m) +${pts}pts`
+ if (window.SoundFX) window.SoundFX.correct()
+ } else {
+ feedbackEl.style.color = "#ef4444"
+ feedbackEl.textContent = `${landingM.toFixed(1)}m โ€” ${error.toFixed(1)}m off! +${pts}pts`
+ if (window.SoundFX) window.SoundFX.wrong()
+ }
+
+ if (attempts >= MAX_ATTEMPTS) {
+ totalScore += bestScore
+ setTimeout(() => {
+ if (round >= TOTAL_ROUNDS) {
+ endGame()
+ } else {
+ feedbackEl.textContent += ` | Best this round: ${bestScore}pts. Next round...`
+ setTimeout(startRound, 1500)
+ }
+ }, 1000)
+ }
+ }
+ }
+
+ requestAnimationFrame(animate)
+ }
+
+ function endGame() {
+ launchBtn.style.display = "none"
+
+ channel.push("projectile_submit", {
+ score: totalScore,
+ target_x: targetX,
+ best_error: parseFloat(bestError.toFixed(2))
+ }).receive("ok", () => {
+ if (window.SoundFX) window.SoundFX.score()
+
+ while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild)
+
+ const result = document.createElement("div")
+ result.style.cssText = "font-size: 1.5rem; color: #22d3ee; margin-bottom: 0.5rem;"
+ result.textContent = `Total Score: ${totalScore}`
+
+ const playAgain = document.createElement("button")
+ playAgain.className = "mg-btn"
+ playAgain.textContent = "Play Again"
+ playAgain.addEventListener("click", () => ProjectileGame.start(container, channel))
+
+ const close = document.createElement("button")
+ close.className = "mg-btn secondary"
+ close.textContent = "Back to Hub"
+ close.addEventListener("click", () => window.piStation.closeMiniGame())
+
+ resultArea.appendChild(result)
+ resultArea.appendChild(playAgain)
+ resultArea.appendChild(close)
+ })
+ }
+
+ channel.on("projectile_score", ({ name, score: s }) => {
+ if (name === window.PLAYER_NAME) return
+ const el = document.createElement("div")
+ el.style.cssText = "color: #22d3ee; font-size: 0.8rem; margin-top: 0.25rem;"
+ el.textContent = `${name} scored ${s} points!`
+ resultArea.appendChild(el)
+ setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 5000)
+ })
+
+ startRound()
+ }
+ }
+
+ function createSlider(label, min, max, initial, unit, onChange) {
+ const container = document.createElement("div")
+ container.style.cssText = "display:flex;align-items:center;gap:0.75rem;margin:0.4rem auto;max-width:400px;color:white;"
+
+ const labelEl = document.createElement("span")
+ labelEl.style.cssText = "width:60px;font-size:0.85rem;text-align:right;"
+ labelEl.textContent = label
+
+ const slider = document.createElement("input")
+ slider.type = "range"
+ slider.min = min
+ slider.max = max
+ slider.value = initial
+ slider.style.cssText = "flex:1;accent-color:#8b5cf6;height:24px;"
+
+ const valueEl = document.createElement("span")
+ valueEl.style.cssText = "width:65px;font-size:0.9rem;color:#22d3ee;font-weight:bold;"
+ valueEl.textContent = `${initial}${unit}`
+
+ slider.addEventListener("input", () => {
+ const v = parseInt(slider.value)
+ valueEl.textContent = `${v}${unit}`
+ onChange(v)
+ })
+
+ container.appendChild(labelEl)
+ container.appendChild(slider)
+ container.appendChild(valueEl)
+
+ return { container, slider, valueEl }
+ }
assets/js/game/TriviaGame.js +219 -0
@@ @@ -0,0 +1,219 @@
+ const TOTAL_QUESTIONS = 10
+ const ROUND_TIME = 15
+
+ export const TriviaGame = {
+ start(container, channel) {
+ while (container.firstChild) container.removeChild(container.firstChild)
+
+ let score = 0
+ let streak = 0
+ let correctCount = 0
+ let questionNum = 0
+ let timerInterval = null
+ let questionStartTime = null
+ let answering = false
+
+ const title = document.createElement("div")
+ title.className = "mg-title"
+ title.textContent = "Pi Trivia Blitz"
+
+ const subtitle = document.createElement("div")
+ subtitle.className = "mg-subtitle"
+ subtitle.textContent = "Math, science & Pi history! Speed gives bonus points."
+
+ const timerEl = document.createElement("div")
+ timerEl.className = "slice-timer"
+
+ const progressEl = document.createElement("div")
+ progressEl.style.cssText = "color: #a78bfa; font-size: 0.9rem; margin: 0.25rem 0;"
+
+ const scoreEl = document.createElement("div")
+ scoreEl.className = "slice-score-display"
+ scoreEl.textContent = "Score: 0 | Streak: 0"
+
+ const questionEl = document.createElement("div")
+ questionEl.className = "slice-question"
+ questionEl.style.fontSize = "1.1rem"
+ questionEl.style.minHeight = "3rem"
+
+ const choicesEl = document.createElement("div")
+ choicesEl.style.cssText = "display:grid;grid-template-columns:1fr;gap:0.5rem;max-width:450px;margin:0.75rem auto;"
+
+ const feedbackEl = document.createElement("div")
+ feedbackEl.style.cssText = "min-height: 2rem; margin: 0.5rem 0; font-size: 1rem;"
+
+ const resultArea = document.createElement("div")
+ resultArea.style.cssText = "margin-top: 1rem;"
+
+ container.appendChild(title)
+ container.appendChild(subtitle)
+ container.appendChild(timerEl)
+ container.appendChild(progressEl)
+ container.appendChild(scoreEl)
+ container.appendChild(questionEl)
+ container.appendChild(choicesEl)
+ container.appendChild(feedbackEl)
+ container.appendChild(resultArea)
+
+ function nextQuestion() {
+ if (questionNum >= TOTAL_QUESTIONS) { endGame(); return }
+
+ answering = true
+ questionNum++
+ progressEl.textContent = `Question ${questionNum} / ${TOTAL_QUESTIONS}`
+ feedbackEl.textContent = ""
+
+ channel.push("trivia_get_question", {}).receive("ok", (problem) => {
+ questionEl.textContent = problem.question
+ questionStartTime = Date.now()
+
+ while (choicesEl.firstChild) choicesEl.removeChild(choicesEl.firstChild)
+
+ problem.choices.forEach(choice => {
+ const btn = document.createElement("button")
+ btn.className = "slice-choice"
+ btn.style.cssText = "padding:0.75rem 1rem;font-size:1rem;background:rgba(255,255,255,0.1);border:2px solid rgba(255,255,255,0.2);border-radius:0.75rem;color:white;cursor:pointer;text-align:left;-webkit-tap-highlight-color:transparent;"
+ btn.textContent = choice
+ btn.addEventListener("click", () => handleAnswer(choice, problem.answer, btn))
+ choicesEl.appendChild(btn)
+ })
+
+ let timeLeft = ROUND_TIME
+ timerEl.textContent = timeLeft
+ timerEl.style.color = "#22d3ee"
+
+ if (timerInterval) clearInterval(timerInterval)
+ timerInterval = setInterval(() => {
+ timeLeft--
+ timerEl.textContent = timeLeft
+ if (timeLeft <= 3) {
+ timerEl.style.color = "#ef4444"
+ if (window.SoundFX) window.SoundFX.tick()
+ } else if (timeLeft <= 5) {
+ timerEl.style.color = "#f59e0b"
+ }
+ if (timeLeft <= 0) { clearInterval(timerInterval); handleTimeout(problem.answer) }
+ }, 1000)
+ })
+ }
+
+ function handleAnswer(chosen, correct, btnEl) {
+ if (!answering) return
+ answering = false
+ clearInterval(timerInterval)
+
+ const timeMs = Date.now() - questionStartTime
+ const isCorrect = chosen === correct
+
+ const buttons = choicesEl.querySelectorAll("button")
+ buttons.forEach(btn => {
+ if (btn.textContent === correct) {
+ btn.style.background = "rgba(34, 197, 94, 0.4)"
+ btn.style.borderColor = "#22c55e"
+ } else if (btn === btnEl && !isCorrect) {
+ btn.style.background = "rgba(239, 68, 68, 0.4)"
+ btn.style.borderColor = "#ef4444"
+ }
+ btn.style.pointerEvents = "none"
+ })
+
+ if (isCorrect) {
+ streak++
+ correctCount++
+ if (window.SoundFX) window.SoundFX.correct()
+ } else {
+ streak = 0
+ if (window.SoundFX) window.SoundFX.wrong()
+ }
+
+ channel.push("trivia_answer", { correct: isCorrect, time_ms: timeMs, streak }).receive("ok", ({ points }) => {
+ score += points
+ scoreEl.textContent = `Score: ${score} | Streak: ${streak}`
+ if (isCorrect) {
+ feedbackEl.style.color = "#22c55e"
+ feedbackEl.textContent = `Correct! +${points} pts (${(timeMs / 1000).toFixed(1)}s)`
+ } else {
+ feedbackEl.style.color = "#ef4444"
+ feedbackEl.textContent = `Wrong! Answer: ${correct}`
+ }
+ setTimeout(nextQuestion, 2000)
+ })
+ }
+
+ function handleTimeout(correct) {
+ answering = false
+ streak = 0
+ if (window.SoundFX) window.SoundFX.wrong()
+ feedbackEl.style.color = "#ef4444"
+ feedbackEl.textContent = `Time's up! Answer: ${correct}`
+ scoreEl.textContent = `Score: ${score} | Streak: ${streak}`
+
+ const buttons = choicesEl.querySelectorAll("button")
+ buttons.forEach(btn => {
+ if (btn.textContent === correct) {
+ btn.style.background = "rgba(34, 197, 94, 0.4)"
+ btn.style.borderColor = "#22c55e"
+ }
+ btn.style.pointerEvents = "none"
+ })
+
+ channel.push("trivia_answer", { correct: false, time_ms: ROUND_TIME * 1000, streak: 0 })
+ setTimeout(nextQuestion, 2000)
+ }
+
+ function endGame() {
+ clearInterval(timerInterval)
+ questionEl.textContent = ""
+ while (choicesEl.firstChild) choicesEl.removeChild(choicesEl.firstChild)
+ timerEl.textContent = ""
+
+ if (window.SoundFX) window.SoundFX.score()
+
+ channel.push("trivia_game_over", { score, correct: correctCount, total: TOTAL_QUESTIONS }).receive("ok", () => {
+ while (resultArea.firstChild) resultArea.removeChild(resultArea.firstChild)
+
+ const result = document.createElement("div")
+ result.style.cssText = "font-size: 1.5rem; color: #22d3ee; margin-bottom: 0.5rem;"
+ result.textContent = `Final Score: ${score}`
+
+ const details = document.createElement("div")
+ details.style.cssText = "color: #a78bfa; margin-bottom: 1rem;"
+ details.textContent = `${correctCount} / ${TOTAL_QUESTIONS} correct`
+
+ const playAgain = document.createElement("button")
+ playAgain.className = "mg-btn"
+ playAgain.textContent = "Play Again"
+ playAgain.addEventListener("click", () => TriviaGame.start(container, channel))
+
+ const close = document.createElement("button")
+ close.className = "mg-btn secondary"
+ close.textContent = "Back to Hub"
+ close.addEventListener("click", () => window.piStation.closeMiniGame())
+
+ resultArea.appendChild(result)
+ resultArea.appendChild(details)
+ resultArea.appendChild(playAgain)
+ resultArea.appendChild(close)
+ })
+ }
+
+ channel.on("trivia_correct", ({ name, streak: s }) => {
+ if (name === window.PLAYER_NAME) return
+ const el = document.createElement("div")
+ el.style.cssText = "color: #a78bfa; font-size: 0.75rem;"
+ el.textContent = `${name} got one right! (streak: ${s})`
+ feedbackEl.appendChild(el)
+ setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 3000)
+ })
+
+ channel.on("trivia_score", ({ name, score: s }) => {
+ const el = document.createElement("div")
+ el.style.cssText = "color: #22d3ee; font-size: 0.8rem; margin-top: 0.25rem;"
+ el.textContent = `${name} finished with ${s} points!`
+ resultArea.appendChild(el)
+ setTimeout(() => { if (el.parentNode) el.parentNode.removeChild(el) }, 5000)
+ })
+
+ nextQuestion()
+ }
+ }
pi_day/game.ex b/lib/pi_day/game.ex +147 -37
@@ @@ -77,70 +77,180 @@ defmodule PiDay.Game do
# --- Math problems for Slice the Pi ---
+ @problem_types [
+ :circumference, :area, :arc_length, :sector_area,
+ :sphere_volume, :sphere_surface, :cylinder_volume,
+ :radians, :trig_value, :diameter_from_area
+ ]
+
def generate_math_problem do
- type = Enum.random([:circumference, :area, :arc_length, :sector_area])
+ type = Enum.random(@problem_types)
generate_problem(type)
end
defp generate_problem(:circumference) do
r = Enum.random(1..20)
answer = Float.round(2 * :math.pi() * r, 2)
- wrong = generate_wrong_answers(answer)
-
- %{
- question: "What is the circumference of a circle with radius #{r}?",
- answer: answer,
- choices: Enum.shuffle([answer | wrong]),
- type: :circumference
- }
+ make_problem("Circumference of a circle with radius #{r}?", answer)
end
defp generate_problem(:area) do
r = Enum.random(1..15)
answer = Float.round(:math.pi() * r * r, 2)
- wrong = generate_wrong_answers(answer)
-
- %{
- question: "What is the area of a circle with radius #{r}?",
- answer: answer,
- choices: Enum.shuffle([answer | wrong]),
- type: :area
- }
+ make_problem("Area of a circle with radius #{r}?", answer)
end
defp generate_problem(:arc_length) do
r = Enum.random(2..12)
angle = Enum.random([30, 45, 60, 90, 120, 180])
answer = Float.round(2 * :math.pi() * r * (angle / 360), 2)
- wrong = generate_wrong_answers(answer)
-
- %{
- question: "Arc length: radius=#{r}, central angle=#{angle} degrees?",
- answer: answer,
- choices: Enum.shuffle([answer | wrong]),
- type: :arc_length
- }
+ make_problem("Arc length: radius=#{r}, angle=#{angle}\u00B0?", answer)
end
defp generate_problem(:sector_area) do
r = Enum.random(2..10)
angle = Enum.random([30, 45, 60, 90, 120, 180])
answer = Float.round(:math.pi() * r * r * (angle / 360), 2)
- wrong = generate_wrong_answers(answer)
+ make_problem("Sector area: radius=#{r}, angle=#{angle}\u00B0?", answer)
+ end
+
+ defp generate_problem(:sphere_volume) do
+ r = Enum.random(1..8)
+ answer = Float.round(4 / 3 * :math.pi() * r * r * r, 2)
+ make_problem("Volume of a sphere with radius #{r}?", answer)
+ end
+
+ defp generate_problem(:sphere_surface) do
+ r = Enum.random(1..10)
+ answer = Float.round(4 * :math.pi() * r * r, 2)
+ make_problem("Surface area of a sphere with radius #{r}?", answer)
+ end
+
+ defp generate_problem(:cylinder_volume) do
+ r = Enum.random(1..8)
+ h = Enum.random(2..10)
+ answer = Float.round(:math.pi() * r * r * h, 2)
+ make_problem("Volume of cylinder: radius=#{r}, height=#{h}?", answer)
+ end
+
+ defp generate_problem(:radians) do
+ {deg, rad_num, rad_den} = Enum.random([{30, 1, 6}, {45, 1, 4}, {60, 1, 3}, {90, 1, 2}, {120, 2, 3}, {180, 1, 1}, {270, 3, 2}, {360, 2, 1}])
+ answer = Float.round(:math.pi() * rad_num / rad_den, 2)
+ make_problem("Convert #{deg}\u00B0 to radians?", answer)
+ end
+
+ defp generate_problem(:trig_value) do
+ {label, value} =
+ Enum.random([
+ {"sin(30\u00B0)", 0.5},
+ {"cos(60\u00B0)", 0.5},
+ {"sin(90\u00B0)", 1.0},
+ {"cos(0\u00B0)", 1.0},
+ {"sin(45\u00B0)", 0.71},
+ {"cos(45\u00B0)", 0.71},
+ {"sin(0\u00B0)", 0.0},
+ {"cos(90\u00B0)", 0.0},
+ {"tan(45\u00B0)", 1.0},
+ {"sin(60\u00B0)", 0.87},
+ {"cos(30\u00B0)", 0.87}
+ ])
+
+ answer = Float.round(value, 2)
+ wrong = generate_trig_wrong(answer)
+ %{question: "What is #{label}?", answer: answer, choices: Enum.shuffle([answer | wrong])}
+ end
- %{
- question: "Sector area: radius=#{r}, angle=#{angle} degrees?",
- answer: answer,
- choices: Enum.shuffle([answer | wrong]),
- type: :sector_area
- }
+ defp generate_problem(:diameter_from_area) do
+ d = Enum.random(2..16)
+ area = Float.round(:math.pi() * (d / 2) * (d / 2), 2)
+ answer = d * 1.0
+ wrong = [d + 2.0, d - 1.0, d * 1.5] |> Enum.map(&Float.round(&1, 2))
+ %{question: "Circle has area #{area}. What is the diameter?", answer: answer, choices: Enum.shuffle([answer | wrong])}
+ end
+
+ defp make_problem(question, answer) do
+ %{question: question, answer: answer, choices: Enum.shuffle([answer | generate_wrong_answers(answer)])}
+ end
+
+ defp generate_wrong_answers(correct) when correct == 0.0 do
+ [0.5, 1.0, -1.0]
end
defp generate_wrong_answers(correct) do
- [
- Float.round(correct * 0.5, 2),
- Float.round(correct * 1.5, 2),
- Float.round(correct + :math.pi(), 2)
- ]
+ offsets = [0.5, 1.5, 1.0 + :math.pi() / correct]
+ Enum.map(offsets, fn mult -> Float.round(correct * mult, 2) end)
+ |> Enum.uniq()
+ |> Enum.reject(&(&1 == correct))
+ |> Enum.take(3)
+ |> case do
+ list when length(list) < 3 ->
+ list ++ Enum.map(1..(3 - length(list)), fn i -> Float.round(correct + i * 1.11, 2) end)
+
+ list ->
+ list
+ end
+ end
+
+ defp generate_trig_wrong(correct) do
+ candidates = [0.0, 0.25, 0.5, 0.71, 0.87, 1.0, 1.41, 1.73, -0.5, -1.0]
+ candidates
+ |> Enum.reject(&(&1 == correct))
+ |> Enum.shuffle()
+ |> Enum.take(3)
+ end
+
+ # --- Trivia Questions ---
+
+ @trivia [
+ %{q: "What is the 3rd digit of Pi after the decimal?", a: "1", choices: ["1", "4", "5", "9"]},
+ %{q: "Who first proved that Pi is irrational?", a: "Johann Lambert", choices: ["Johann Lambert", "Leonhard Euler", "Isaac Newton", "Archimedes"]},
+ %{q: "What is Euler's identity: e^(i\u03C0) + 1 = ?", a: "0", choices: ["0", "1", "\u03C0", "i"]},
+ %{q: "Pi Day (3/14) is also whose birthday?", a: "Albert Einstein", choices: ["Albert Einstein", "Isaac Newton", "Stephen Hawking", "Nikola Tesla"]},
+ %{q: "Approximately how many digits of Pi have been computed?", a: "Over 100 trillion", choices: ["Over 100 trillion", "About 1 billion", "About 10 billion", "Over 1 quadrillion"]},
+ %{q: "What ancient civilization first estimated Pi?", a: "Babylonians", choices: ["Babylonians", "Romans", "Chinese", "Aztecs"]},
+ %{q: "The ratio of a circle's circumference to its diameter is:", a: "Pi", choices: ["Pi", "2\u03C0", "Tau", "Phi"]},
+ %{q: "What is Tau (\u03C4)?", a: "2\u03C0", choices: ["2\u03C0", "\u03C0/2", "\u03C0\u00B2", "\u221A\u03C0"]},
+ %{q: "The Golden Ratio (Phi) is approximately:", a: "1.618", choices: ["1.618", "3.14", "2.718", "1.414"]},
+ %{q: "What is e (Euler's number) approximately?", a: "2.718", choices: ["2.718", "3.14", "1.618", "2.236"]},
+ %{q: "What shape has the largest area for a given perimeter?", a: "Circle", choices: ["Circle", "Square", "Equilateral triangle", "Regular hexagon"]},
+ %{q: "How many radians in a full circle?", a: "2\u03C0", choices: ["2\u03C0", "\u03C0", "360", "4\u03C0"]},
+ %{q: "What is i\u00B2 (imaginary unit squared)?", a: "-1", choices: ["-1", "1", "i", "0"]},
+ %{q: "Archimedes estimated Pi by inscribing polygons with how many sides?", a: "96", choices: ["96", "12", "36", "360"]},
+ %{q: "What is the sum of angles in a triangle (degrees)?", a: "180", choices: ["180", "360", "90", "270"]},
+ %{q: "What is \u221A2 approximately?", a: "1.414", choices: ["1.414", "1.618", "1.732", "1.234"]},
+ %{q: "The number Pi is:", a: "Irrational and transcendental", choices: ["Irrational and transcendental", "Rational", "Irrational but algebraic", "Imaginary"]},
+ %{q: "What is the formula for a circle's area?", a: "\u03C0r\u00B2", choices: ["\u03C0r\u00B2", "2\u03C0r", "\u03C0d", "\u03C0r\u00B3"]},
+ %{q: "sin\u00B2(x) + cos\u00B2(x) = ?", a: "1", choices: ["1", "0", "\u03C0", "2"]},
+ %{q: "What is 0! (zero factorial)?", a: "1", choices: ["1", "0", "Undefined", "\u221E"]},
+ %{q: "The Pythagorean theorem states: a\u00B2 + b\u00B2 = ?", a: "c\u00B2", choices: ["c\u00B2", "ab", "2c", "(a+b)\u00B2"]},
+ %{q: "What is the derivative of sin(x)?", a: "cos(x)", choices: ["cos(x)", "-sin(x)", "tan(x)", "-cos(x)"]},
+ %{q: "What mathematical constant appears in the normal distribution?", a: "Both Pi and e", choices: ["Both Pi and e", "Only Pi", "Only e", "Neither"]},
+ %{q: "How is Pi defined geometrically?", a: "Circumference \u00F7 Diameter", choices: ["Circumference \u00F7 Diameter", "Area \u00F7 Radius", "Diameter \u00F7 Radius", "Perimeter \u00F7 Side"]},
+ %{q: "Who used the symbol \u03C0 for the first time for this constant?", a: "William Jones", choices: ["William Jones", "Leonhard Euler", "Isaac Newton", "Carl Gauss"]},
+ %{q: "What is the integral of 1/x?", a: "ln|x| + C", choices: ["ln|x| + C", "x\u00B2 + C", "1/x\u00B2 + C", "e^x + C"]},
+ %{q: "What is the volume of a sphere formula?", a: "(4/3)\u03C0r\u00B3", choices: ["(4/3)\u03C0r\u00B3", "4\u03C0r\u00B2", "\u03C0r\u00B2h", "2\u03C0r\u00B3"]},
+ %{q: "The Fibonacci sequence approaches what ratio?", a: "The Golden Ratio", choices: ["The Golden Ratio", "Pi", "e", "The Silver Ratio"]},
+ %{q: "What is the billionth digit of Pi?", a: "9", choices: ["9", "1", "7", "3"]},
+ %{q: "Buffon's Needle experiment can estimate:", a: "Pi", choices: ["Pi", "e", "The Golden Ratio", "Gravity"]}
+ ]
+
+ def get_trivia_question do
+ q = Enum.random(@trivia)
+ %{question: q.q, answer: q.a, choices: Enum.shuffle(q.choices)}
+ end
+
+ # --- Projectile Pi ---
+
+ def generate_projectile_target do
+ # Target at Pi-related distances
+ target_x = Enum.random([
+ Float.round(:math.pi() * 10, 1),
+ Float.round(:math.pi() * 20, 1),
+ Float.round(:math.pi() * 15, 1),
+ Float.round(:math.pi() * 25, 1),
+ Float.round(:math.pi() * 30, 1)
+ ])
+
+ %{target_x: target_x, gravity: 9.81}
end
end
pi_day/game/score.ex b/lib/pi_day/game/score.ex +1 -1
@@ @@ -15,7 +15,7 @@ defmodule PiDay.Game.Score do
timestamps(type: :utc_datetime, updated_at: false)
end
- @game_types ~w(pi_memory monte_carlo slice_the_pi)
+ @game_types ~w(pi_memory monte_carlo slice_the_pi pi_trivia projectile_pi)
def changeset(score, attrs) do
score
pi_day_web/channels/mini_game_channel.ex b/lib/pi_day_web/channels/mini_game_channel.ex +70 -1
@@ @@ -3,8 +3,10 @@ defmodule PiDayWeb.MiniGameChannel do
alias PiDay.Game
+ @valid_games ~w(pi_memory monte_carlo slice_the_pi pi_trivia projectile_pi)
+
@impl true
- def join("game:mini:" <> game_type, _payload, socket) when game_type in ~w(pi_memory monte_carlo slice_the_pi) do
+ def join("game:mini:" <> game_type, _payload, socket) when game_type in @valid_games do
socket = assign(socket, :game_type, game_type)
{:ok, socket}
end
@@ @@ -111,4 +113,71 @@ defmodule PiDayWeb.MiniGameChannel do
{:reply, {:ok, %{recorded: true}}, socket}
end
+
+ # --- Pi Trivia Blitz ---
+
+ def handle_in("trivia_get_question", _payload, socket) do
+ question = Game.get_trivia_question()
+ {:reply, {:ok, question}, socket}
+ end
+
+ def handle_in("trivia_answer", %{"correct" => correct, "time_ms" => time_ms, "streak" => streak}, socket) do
+ player = socket.assigns.player
+ points = if correct, do: max(10, 150 - div(time_ms, 100)) + streak * 15, else: 0
+
+ if correct do
+ broadcast!(socket, "trivia_correct", %{
+ player_id: player.id,
+ name: player.name,
+ streak: streak
+ })
+ end
+
+ {:reply, {:ok, %{points: points}}, socket}
+ end
+
+ def handle_in("trivia_game_over", %{"score" => score, "correct" => correct, "total" => total}, socket) do
+ player = socket.assigns.player
+
+ Game.record_score(%{
+ player_id: player.id,
+ game_type: "pi_trivia",
+ score: score,
+ metadata: %{correct: correct, total: total}
+ })
+
+ broadcast!(socket, "trivia_score", %{
+ player_id: player.id,
+ name: player.name,
+ score: score
+ })
+
+ {:reply, {:ok, %{recorded: true}}, socket}
+ end
+
+ # --- Projectile Pi ---
+
+ def handle_in("projectile_get_target", _payload, socket) do
+ target = Game.generate_projectile_target()
+ {:reply, {:ok, target}, socket}
+ end
+
+ def handle_in("projectile_submit", %{"score" => score, "target_x" => target_x, "best_error" => best_error}, socket) do
+ player = socket.assigns.player
+
+ Game.record_score(%{
+ player_id: player.id,
+ game_type: "projectile_pi",
+ score: score,
+ metadata: %{target_x: target_x, best_error: best_error}
+ })
+
+ broadcast!(socket, "projectile_score", %{
+ player_id: player.id,
+ name: player.name,
+ score: score
+ })
+
+ {:reply, {:ok, %{recorded: true}}, socket}
+ end
end
pi_day_web/live/spectate_live.ex b/lib/pi_day_web/live/spectate_live.ex +21 -18
@@ @@ -20,19 +20,26 @@ defmodule PiDayWeb.SpectateLive do
leaderboard: Game.leaderboard(),
pi_top: Game.top_scores("pi_memory", 5),
mc_top: Game.top_scores("monte_carlo", 5),
- slice_top: Game.top_scores("slice_the_pi", 5)
+ slice_top: Game.top_scores("slice_the_pi", 5),
+ trivia_top: Game.top_scores("pi_trivia", 5),
+ projectile_top: Game.top_scores("projectile_pi", 5)
)}
end
+ defp refresh(socket) do
+ assign(socket,
+ leaderboard: Game.leaderboard(),
+ pi_top: Game.top_scores("pi_memory", 5),
+ mc_top: Game.top_scores("monte_carlo", 5),
+ slice_top: Game.top_scores("slice_the_pi", 5),
+ trivia_top: Game.top_scores("pi_trivia", 5),
+ projectile_top: Game.top_scores("projectile_pi", 5)
+ )
+ end
+
@impl true
def handle_info(:refresh_leaderboard, socket) do
- {:noreply,
- assign(socket,
- leaderboard: Game.leaderboard(),
- pi_top: Game.top_scores("pi_memory", 5),
- mc_top: Game.top_scores("monte_carlo", 5),
- slice_top: Game.top_scores("slice_the_pi", 5)
- )}
+ {:noreply, refresh(socket)}
end
def handle_info(:push_presence, socket) do
@@ @@ -46,13 +53,7 @@ defmodule PiDayWeb.SpectateLive do
end
def handle_info({:score_updated, _score}, socket) do
- {:noreply,
- assign(socket,
- leaderboard: Game.leaderboard(),
- pi_top: Game.top_scores("pi_memory", 5),
- mc_top: Game.top_scores("monte_carlo", 5),
- slice_top: Game.top_scores("slice_the_pi", 5)
- )}
+ {:noreply, refresh(socket)}
end
# Ignore presence diffs โ€” we poll with push_presence instead
@@ @@ -102,10 +103,12 @@ defmodule PiDayWeb.SpectateLive do
</div>
<!-- Mini-game leaderboards -->
- <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
- <.mini_leaderboard title="Pi Memory Sprint" icon="๐Ÿง " scores={@pi_top} />
- <.mini_leaderboard title="Monte Carlo Pi" icon="๐ŸŽฏ" scores={@mc_top} />
+ <div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4 mt-6">
+ <.mini_leaderboard title="Pi Memory" icon="๐Ÿง " scores={@pi_top} />
+ <.mini_leaderboard title="Monte Carlo" icon="๐ŸŽฏ" scores={@mc_top} />
<.mini_leaderboard title="Slice the Pi" icon="๐Ÿ”ช" scores={@slice_top} />
+ <.mini_leaderboard title="Pi Trivia" icon="๐Ÿ’ก" scores={@trivia_top} />
+ <.mini_leaderboard title="Projectile Pi" icon="๐Ÿš€" scores={@projectile_top} />
</div>
<div class="text-center mt-6 text-purple-400/50 text-sm font-mono">