feat(arcade): add Math Sprint game implementation

- Implement free-for-all math racing game
- Demonstrates TEAM_MOVE pattern (no specific turn owner)
- Server-generated math questions (addition, subtraction, multiplication)
- Real-time competitive gameplay with scoring
- Three difficulty levels: easy, medium, hard
- Configurable questions per round and time limits

Components:
- SetupPhase: Configure difficulty, questions, time
- PlayingPhase: Answer questions competitively
- ResultsPhase: Display final scores and winner
- Validator: Server-side question generation and validation

Game follows SDK patterns established by Number Guesser.
This commit is contained in:
Thomas Hallock
2025-10-15 21:10:53 -05:00
parent 693fe6bb9f
commit e5be09ef5f
8 changed files with 1478 additions and 0 deletions

View File

@@ -0,0 +1,198 @@
/**
* Math Sprint Provider
*
* Context provider for Math Sprint game state management.
* Demonstrates free-for-all gameplay with TEAM_MOVE pattern.
*/
'use client'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import {
buildPlayerMetadata,
useArcadeSession,
useGameMode,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type { Difficulty, MathSprintState } from './types'
/**
* Context value provided to child components
*/
interface MathSprintContextValue {
state: MathSprintState
lastError: string | null
startGame: () => void
submitAnswer: (answer: number) => void
nextQuestion: () => void
resetGame: () => void
setConfig: (field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => void
clearError: () => void
exitSession: () => void
}
const MathSprintContext = createContext<MathSprintContextValue | null>(null)
/**
* Hook to access Math Sprint context
*/
export function useMathSprint() {
const context = useContext(MathSprintContext)
if (!context) {
throw new Error('useMathSprint must be used within MathSprintProvider')
}
return context
}
/**
* Math Sprint Provider Component
*/
export function MathSprintProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Get active players as array (keep Set iteration order)
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room with defaults
const gameConfig = useMemo(() => {
const allGameConfigs = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = allGameConfigs?.['math-sprint'] as Record<string, unknown> | undefined
return {
difficulty: (savedConfig?.difficulty as Difficulty) || 'medium',
questionsPerRound: (savedConfig?.questionsPerRound as number) || 10,
timePerQuestion: (savedConfig?.timePerQuestion as number) || 30,
}
}, [roomData?.gameConfig])
// Initial state with merged config
const initialState = useMemo<MathSprintState>(
() => ({
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
difficulty: gameConfig.difficulty,
questionsPerRound: gameConfig.questionsPerRound,
timePerQuestion: gameConfig.timePerQuestion,
currentQuestionIndex: 0,
questions: [],
scores: {},
correctAnswersCount: {},
answers: [],
questionStartTime: 0,
questionAnswered: false,
winnerId: null,
}),
[gameConfig]
)
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } =
useArcadeSession<MathSprintState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: (state) => state, // Server handles all state updates
})
// Action: Start game
const startGame = useCallback(() => {
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
sendMove({
type: 'START_GAME',
playerId: TEAM_MOVE, // Free-for-all: no specific turn owner
userId: viewerId || '',
data: { activePlayers, playerMetadata },
})
}, [activePlayers, players, viewerId, sendMove])
// Action: Submit answer
const submitAnswer = useCallback(
(answer: number) => {
// Find this user's player ID from game state
const myPlayerId = state.activePlayers.find((pid) => {
return state.playerMetadata[pid]?.userId === viewerId
})
if (!myPlayerId) {
console.error('[MathSprint] No player found for current user')
return
}
sendMove({
type: 'SUBMIT_ANSWER',
playerId: myPlayerId, // Specific player answering
userId: viewerId || '',
data: { answer },
})
},
[state.activePlayers, state.playerMetadata, viewerId, sendMove]
)
// Action: Next question
const nextQuestion = useCallback(() => {
sendMove({
type: 'NEXT_QUESTION',
playerId: TEAM_MOVE, // Any player can advance
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
// Action: Reset game
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_GAME',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
// Action: Set config
const setConfig = useCallback(
(field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
// Persist to database for next session
if (roomData?.id) {
updateGameConfig({
roomId: roomData.id,
gameConfig: {
...roomData.gameConfig,
'math-sprint': {
...(roomData.gameConfig?.['math-sprint'] || {}),
[field]: value,
},
},
})
}
},
[viewerId, sendMove, updateGameConfig, roomData]
)
const contextValue: MathSprintContextValue = {
state,
lastError,
startGame,
submitAnswer,
nextQuestion,
resetGame,
setConfig,
clearError,
exitSession,
}
return <MathSprintContext.Provider value={contextValue}>{children}</MathSprintContext.Provider>
}

View File

@@ -0,0 +1,340 @@
/**
* Math Sprint Validator
*
* Server-side validation for Math Sprint game.
* Generates questions, validates answers, awards points.
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import type {
Difficulty,
MathSprintConfig,
MathSprintMove,
MathSprintState,
Operation,
Question,
} from './types'
export class MathSprintValidator
implements GameValidator<MathSprintState, MathSprintMove>
{
/**
* Validate a game move
*/
validateMove(
state: MathSprintState,
move: MathSprintMove,
context?: { userId?: string }
): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
case 'SUBMIT_ANSWER':
return this.validateSubmitAnswer(
state,
move.playerId,
Number(move.data.answer),
move.timestamp
)
case 'NEXT_QUESTION':
return this.validateNextQuestion(state)
case 'RESET_GAME':
return this.validateResetGame(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
default:
return { valid: false, error: 'Unknown move type' }
}
}
/**
* Check if game is complete
*/
isGameComplete(state: MathSprintState): boolean {
return state.gamePhase === 'results'
}
/**
* Get initial state for new game
*/
getInitialState(config: unknown): MathSprintState {
const { difficulty, questionsPerRound, timePerQuestion } = config as MathSprintConfig
return {
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
difficulty: difficulty || 'medium',
questionsPerRound: questionsPerRound || 10,
timePerQuestion: timePerQuestion || 30,
currentQuestionIndex: 0,
questions: [],
scores: {},
correctAnswersCount: {},
answers: [],
questionStartTime: 0,
questionAnswered: false,
winnerId: null,
}
}
// ============================================================================
// Validation Methods
// ============================================================================
private validateStartGame(
state: MathSprintState,
activePlayers: string[],
playerMetadata: Record<string, any>
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Game already started' }
}
if (activePlayers.length < 2) {
return { valid: false, error: 'Need at least 2 players' }
}
// Generate questions
const questions = this.generateQuestions(state.difficulty, state.questionsPerRound)
const newState: MathSprintState = {
...state,
gamePhase: 'playing',
activePlayers,
playerMetadata,
questions,
currentQuestionIndex: 0,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
correctAnswersCount: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
answers: [],
questionStartTime: Date.now(),
questionAnswered: false,
winnerId: null,
}
return { valid: true, newState }
}
private validateSubmitAnswer(
state: MathSprintState,
playerId: string,
answer: number,
timestamp: number
): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in progress' }
}
if (!state.activePlayers.includes(playerId)) {
return { valid: false, error: 'Player not in game' }
}
if (state.questionAnswered) {
return { valid: false, error: 'Question already answered correctly' }
}
// Check if player already answered this question
const alreadyAnswered = state.answers.some((a) => a.playerId === playerId)
if (alreadyAnswered) {
return { valid: false, error: 'You already answered this question' }
}
const currentQuestion = state.questions[state.currentQuestionIndex]
const correct = answer === currentQuestion.correctAnswer
const answerRecord = {
playerId,
answer,
timestamp,
correct,
}
const newAnswers = [...state.answers, answerRecord]
let newState = { ...state, answers: newAnswers }
// If correct, award points and mark question as answered
if (correct) {
newState = {
...newState,
questionAnswered: true,
winnerId: playerId,
scores: {
...state.scores,
[playerId]: state.scores[playerId] + 10,
},
correctAnswersCount: {
...state.correctAnswersCount,
[playerId]: state.correctAnswersCount[playerId] + 1,
},
}
}
return { valid: true, newState }
}
private validateNextQuestion(state: MathSprintState): ValidationResult {
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Game not in progress' }
}
if (!state.questionAnswered) {
return { valid: false, error: 'Current question not answered yet' }
}
const isLastQuestion = state.currentQuestionIndex >= state.questions.length - 1
if (isLastQuestion) {
// Game complete, go to results
const newState: MathSprintState = {
...state,
gamePhase: 'results',
}
return { valid: true, newState }
}
// Move to next question
const newState: MathSprintState = {
...state,
currentQuestionIndex: state.currentQuestionIndex + 1,
answers: [],
questionStartTime: Date.now(),
questionAnswered: false,
winnerId: null,
}
return { valid: true, newState }
}
private validateResetGame(state: MathSprintState): ValidationResult {
const newState = this.getInitialState({
difficulty: state.difficulty,
questionsPerRound: state.questionsPerRound,
timePerQuestion: state.timePerQuestion,
})
return { valid: true, newState }
}
private validateSetConfig(
state: MathSprintState,
field: string,
value: any
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Cannot change config during game' }
}
const newState = {
...state,
[field]: value,
}
return { valid: true, newState }
}
// ============================================================================
// Question Generation
// ============================================================================
private generateQuestions(difficulty: Difficulty, count: number): Question[] {
const questions: Question[] = []
for (let i = 0; i < count; i++) {
const operation = this.randomOperation()
const question = this.generateQuestion(difficulty, operation, `q-${i}`)
questions.push(question)
}
return questions
}
private generateQuestion(
difficulty: Difficulty,
operation: Operation,
id: string
): Question {
let operand1: number
let operand2: number
let correctAnswer: number
switch (difficulty) {
case 'easy':
operand1 = this.randomInt(1, 10)
operand2 = this.randomInt(1, 10)
break
case 'medium':
operand1 = this.randomInt(10, 50)
operand2 = this.randomInt(1, 20)
break
case 'hard':
operand1 = this.randomInt(10, 100)
operand2 = this.randomInt(10, 50)
break
}
switch (operation) {
case 'addition':
correctAnswer = operand1 + operand2
break
case 'subtraction':
// Ensure positive result
if (operand1 < operand2) {
;[operand1, operand2] = [operand2, operand1]
}
correctAnswer = operand1 - operand2
break
case 'multiplication':
// Smaller numbers for multiplication
if (difficulty === 'hard') {
operand1 = this.randomInt(2, 20)
operand2 = this.randomInt(2, 12)
} else {
operand1 = this.randomInt(2, 10)
operand2 = this.randomInt(2, 10)
}
correctAnswer = operand1 * operand2
break
}
const operationSymbol = this.getOperationSymbol(operation)
const displayText = `${operand1} ${operationSymbol} ${operand2} = ?`
return {
id,
operand1,
operand2,
operation,
correctAnswer,
displayText,
}
}
private randomOperation(): Operation {
const operations: Operation[] = ['addition', 'subtraction', 'multiplication']
return operations[Math.floor(Math.random() * operations.length)]
}
private randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
private getOperationSymbol(operation: Operation): string {
switch (operation) {
case 'addition':
return '+'
case 'subtraction':
return ''
case 'multiplication':
return '×'
}
}
}
export const mathSprintValidator = new MathSprintValidator()

View File

@@ -0,0 +1,40 @@
/**
* Math Sprint - Game Component
*
* Main wrapper component with navigation and phase routing.
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useMathSprint } from '../Provider'
import { PlayingPhase } from './PlayingPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, resetGame } = useMathSprint()
return (
<PageWithNav
navTitle="Math Sprint"
navEmoji="🧮"
emphasizePlayerSelection={state.gamePhase === 'setup'}
// No currentPlayerId - free-for-all game, everyone can act simultaneously
playerScores={state.scores}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
resetGame()
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'playing' && <PlayingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</PageWithNav>
)
}

View File

@@ -0,0 +1,347 @@
/**
* Math Sprint - Playing Phase
*
* Main gameplay: show question, accept answers, show feedback.
*/
'use client'
import { useEffect, useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
export function PlayingPhase() {
const { state, submitAnswer, nextQuestion, lastError, clearError } = useMathSprint()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const currentQuestion = state.questions[state.currentQuestionIndex]
const progress = `${state.currentQuestionIndex + 1} / ${state.questions.length}`
// Find if current user answered
const myPlayerId = Object.keys(state.playerMetadata).find(
(pid) => state.playerMetadata[pid]?.userId === viewerId
)
const myAnswer = state.answers.find((a) => a.playerId === myPlayerId)
// Auto-clear error after 3 seconds
useEffect(() => {
if (lastError) {
const timeout = setTimeout(() => clearError(), 3000)
return () => clearTimeout(timeout)
}
}, [lastError, clearError])
// Clear input after question changes
useEffect(() => {
setInputValue('')
}, [state.currentQuestionIndex])
const handleSubmit = () => {
const answer = Number.parseInt(inputValue, 10)
if (Number.isNaN(answer)) return
submitAnswer(answer)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit()
}
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '700px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Progress Bar */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
})}
>
<span className={css({ fontSize: 'sm', fontWeight: 'semibold' })}>Question {progress}</span>
<span className={css({ fontSize: 'sm', color: 'gray.600' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</span>
</div>
<div
className={css({
background: 'gray.200',
height: '8px',
borderRadius: '4px',
overflow: 'hidden',
})}
>
<div
className={css({
background: 'linear-gradient(90deg, #a78bfa, #8b5cf6)',
height: '100%',
borderRadius: '4px',
transition: 'width 0.3s',
})}
style={{
width: `${((state.currentQuestionIndex + 1) / state.questions.length) * 100}%`,
}}
/>
</div>
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
border: '2px solid',
borderColor: 'red.300',
borderRadius: '12px',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span></span>
<span className={css({ fontSize: 'sm', color: 'red.700' })}>{lastError}</span>
</div>
<button
type="button"
onClick={clearError}
className={css({
fontSize: 'xs',
padding: '4px 8px',
background: 'red.100',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
_hover: { background: 'red.200' },
})}
>
Dismiss
</button>
</div>
)}
{/* Question Display */}
<div
className={css({
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
border: '2px solid',
borderColor: 'purple.300',
borderRadius: '16px',
padding: '48px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '4xl',
fontWeight: 'bold',
color: 'purple.700',
fontFamily: 'monospace',
})}
>
{currentQuestion.displayText}
</div>
</div>
{/* Answer Input */}
{!state.questionAnswered && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: myAnswer ? 'gray.300' : 'purple.500',
borderRadius: '12px',
padding: '24px',
})}
>
{myAnswer ? (
<div className={css({ textAlign: 'center' })}>
<div
className={css({
fontSize: 'lg',
color: 'gray.600',
marginBottom: '8px',
})}
>
Your answer: <strong>{myAnswer.answer}</strong>
</div>
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
Waiting for others or correct answer...
</div>
</div>
) : (
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'semibold',
marginBottom: '8px',
})}
>
Your Answer
</label>
<div className={css({ display: 'flex', gap: '12px' })}>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your answer..."
autoFocus
className={css({
flex: 1,
padding: '12px 16px',
fontSize: 'lg',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
_focus: {
outline: 'none',
borderColor: 'purple.500',
},
})}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue}
className={css({
padding: '12px 24px',
fontSize: 'md',
fontWeight: 'semibold',
color: 'white',
background: inputValue ? 'purple.600' : 'gray.400',
border: 'none',
borderRadius: '8px',
cursor: inputValue ? 'pointer' : 'not-allowed',
_hover: {
background: inputValue ? 'purple.700' : 'gray.400',
},
})}
>
Submit
</button>
</div>
</div>
)}
</div>
)}
{/* Winner Display */}
{state.questionAnswered && state.winnerId && (
<div
className={css({
background: 'linear-gradient(135deg, #d1fae5, #a7f3d0)',
border: '2px solid',
borderColor: 'green.400',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '3xl', marginBottom: '8px' })}>🎉</div>
<div className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'green.700' })}>
{state.playerMetadata[state.winnerId]?.name || 'Someone'} got it right!
</div>
<div className={css({ fontSize: 'md', color: 'green.600', marginTop: '4px' })}>
Answer: {currentQuestion.correctAnswer}
</div>
<button
type="button"
onClick={nextQuestion}
className={css({
marginTop: '16px',
padding: '12px 32px',
fontSize: 'md',
fontWeight: 'semibold',
color: 'white',
background: 'green.600',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
_hover: { background: 'green.700' },
})}
>
Next Question
</button>
</div>
)}
{/* Scoreboard */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
marginBottom: '12px',
})}
>
Scores
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '8px' })}>
{Object.entries(state.scores)
.sort(([, a], [, b]) => b - a)
.map(([playerId, score]) => {
const player = state.playerMetadata[playerId]
return (
<div
key={playerId}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
background: 'gray.50',
borderRadius: '8px',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: 'xl' })}>{player?.emoji}</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium' })}>
{player?.name}
</span>
</div>
<span className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'purple.600' })}>
{score} pts
</span>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,194 @@
/**
* Math Sprint - Results Phase
*
* Show final scores and winner.
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
export function ResultsPhase() {
const { state, resetGame } = useMathSprint()
// Sort players by score
const sortedPlayers = Object.entries(state.scores)
.map(([playerId, score]) => ({
playerId,
score,
correct: state.correctAnswersCount[playerId] || 0,
player: state.playerMetadata[playerId],
}))
.sort((a, b) => b.score - a.score)
const winner = sortedPlayers[0]
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '600px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Winner Announcement */}
<div
className={css({
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '2px solid',
borderColor: 'yellow.400',
borderRadius: '16px',
padding: '32px',
textAlign: 'center',
})}
>
<div className={css({ fontSize: '4xl', marginBottom: '12px' })}>🏆</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'yellow.800',
marginBottom: '8px',
})}
>
{winner.player?.name} Wins!
</h2>
<div className={css({ fontSize: 'lg', color: 'yellow.700' })}>
{winner.score} points {winner.correct} correct
</div>
</div>
{/* Final Scores */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
marginBottom: '16px',
})}
>
Final Scores
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '12px' })}>
{sortedPlayers.map((item, index) => (
<div
key={item.playerId}
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '16px',
background: index === 0 ? 'linear-gradient(135deg, #fef3c7, #fde68a)' : 'gray.50',
border: '1px solid',
borderColor: index === 0 ? 'yellow.300' : 'gray.200',
borderRadius: '12px',
})}
>
{/* Rank */}
<div
className={css({
width: '32px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: index === 0 ? 'yellow.500' : 'gray.300',
color: index === 0 ? 'white' : 'gray.700',
borderRadius: '50%',
fontWeight: 'bold',
fontSize: 'sm',
})}
>
{index + 1}
</div>
{/* Player Info */}
<div className={css({ flex: 1 })}>
<div className={css({ display: 'flex', alignItems: 'center', gap: '8px' })}>
<span className={css({ fontSize: 'xl' })}>{item.player?.emoji}</span>
<span className={css({ fontSize: 'md', fontWeight: 'semibold' })}>
{item.player?.name}
</span>
</div>
<div className={css({ fontSize: 'xs', color: 'gray.600', marginTop: '2px' })}>
{item.correct} / {state.questions.length} correct
</div>
</div>
{/* Score */}
<div
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: index === 0 ? 'yellow.700' : 'purple.600',
})}
>
{item.score}
</div>
</div>
))}
</div>
</div>
{/* Stats */}
<div
className={css({
background: 'linear-gradient(135deg, #ede9fe, #ddd6fe)',
border: '1px solid',
borderColor: 'purple.300',
borderRadius: '12px',
padding: '20px',
})}
>
<div className={css({ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' })}>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
{state.questions.length}
</div>
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Questions</div>
</div>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'purple.700' })}>
{state.difficulty.charAt(0).toUpperCase() + state.difficulty.slice(1)}
</div>
<div className={css({ fontSize: 'sm', color: 'purple.600' })}>Difficulty</div>
</div>
</div>
</div>
{/* Play Again Button */}
<button
type="button"
onClick={resetGame}
className={css({
padding: '14px 28px',
fontSize: 'lg',
fontWeight: 'semibold',
color: 'white',
background: 'purple.600',
border: 'none',
borderRadius: '12px',
cursor: 'pointer',
transition: 'background 0.2s',
_hover: {
background: 'purple.700',
},
})}
>
Play Again
</button>
</div>
)
}

View File

@@ -0,0 +1,196 @@
/**
* Math Sprint - Setup Phase
*
* Configure game settings before starting.
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useMathSprint } from '../Provider'
import type { Difficulty } from '../types'
export function SetupPhase() {
const { state, startGame, setConfig } = useMathSprint()
const handleDifficultyChange = (difficulty: Difficulty) => {
setConfig('difficulty', difficulty)
}
const handleQuestionsChange = (questions: number) => {
setConfig('questionsPerRound', questions)
}
return (
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '24px',
maxWidth: '600px',
margin: '0 auto',
padding: '32px 20px',
})}
>
{/* Game Title */}
<div className={css({ textAlign: 'center' })}>
<h1
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'purple.700',
marginBottom: '8px',
})}
>
🧮 Math Sprint
</h1>
<p className={css({ color: 'gray.600' })}>
Race to solve math problems! First correct answer wins points.
</p>
</div>
{/* Settings Card */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '24px',
})}
>
<h2
className={css({
fontSize: 'lg',
fontWeight: 'semibold',
marginBottom: '16px',
})}
>
Game Settings
</h2>
{/* Difficulty */}
<div className={css({ marginBottom: '20px' })}>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
marginBottom: '8px',
})}
>
Difficulty
</label>
<div className={css({ display: 'flex', gap: '8px' })}>
{(['easy', 'medium', 'hard'] as Difficulty[]).map((diff) => (
<button
key={diff}
type="button"
onClick={() => handleDifficultyChange(diff)}
className={css({
flex: 1,
padding: '10px 16px',
borderRadius: '8px',
border: '2px solid',
borderColor: state.difficulty === diff ? 'purple.500' : 'gray.300',
background: state.difficulty === diff ? 'purple.50' : 'white',
color: state.difficulty === diff ? 'purple.700' : 'gray.700',
fontWeight: state.difficulty === diff ? 'semibold' : 'normal',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'purple.400',
},
})}
>
{diff.charAt(0).toUpperCase() + diff.slice(1)}
</button>
))}
</div>
<p className={css({ fontSize: 'xs', color: 'gray.500', marginTop: '4px' })}>
{state.difficulty === 'easy' && 'Numbers 1-10, simple operations'}
{state.difficulty === 'medium' && 'Numbers 1-50, varied operations'}
{state.difficulty === 'hard' && 'Numbers 1-100, harder calculations'}
</p>
</div>
{/* Questions Per Round */}
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
marginBottom: '8px',
})}
>
Questions: {state.questionsPerRound}
</label>
<input
type="range"
min="5"
max="20"
step="5"
value={state.questionsPerRound}
onChange={(e) => handleQuestionsChange(Number(e.target.value))}
className={css({
width: '100%',
})}
/>
<div className={css({ display: 'flex', justifyContent: 'space-between', fontSize: 'xs' })}>
<span>5</span>
<span>10</span>
<span>15</span>
<span>20</span>
</div>
</div>
</div>
{/* Instructions */}
<div
className={css({
background: 'linear-gradient(135deg, #fef3c7, #fde68a)',
border: '1px solid',
borderColor: 'yellow.300',
borderRadius: '12px',
padding: '16px',
})}
>
<h3 className={css({ fontSize: 'sm', fontWeight: 'semibold', marginBottom: '8px' })}>
How to Play
</h3>
<ul className={css({ fontSize: 'sm', color: 'gray.700', paddingLeft: '20px' })}>
<li>Solve math problems as fast as you can</li>
<li>First correct answer earns 10 points</li>
<li>Everyone can answer at the same time</li>
<li>Most points wins!</li>
</ul>
</div>
{/* Start Button */}
<button
type="button"
onClick={startGame}
disabled={state.activePlayers.length < 2}
className={css({
padding: '14px 28px',
fontSize: 'lg',
fontWeight: 'semibold',
color: 'white',
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.600',
borderRadius: '12px',
border: 'none',
cursor: state.activePlayers.length < 2 ? 'not-allowed' : 'pointer',
transition: 'all 0.2s',
_hover: {
background: state.activePlayers.length < 2 ? 'gray.400' : 'purple.700',
},
})}
>
{state.activePlayers.length < 2
? `Need ${2 - state.activePlayers.length} more player(s)`
: 'Start Game'}
</button>
</div>
)
}

View File

@@ -0,0 +1,43 @@
/**
* Math Sprint Game Definition
*
* A free-for-all math game demonstrating the TEAM_MOVE pattern.
* Players race to solve math problems - first correct answer wins points.
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { MathSprintProvider } from './Provider'
import type { MathSprintConfig, MathSprintMove, MathSprintState } from './types'
import { mathSprintValidator } from './Validator'
const manifest: GameManifest = {
name: 'math-sprint',
displayName: 'Math Sprint',
icon: '🧮',
description: 'Race to solve math problems!',
longDescription:
'A fast-paced free-for-all game where players compete to solve math problems. First correct answer earns points. Choose your difficulty and test your mental math skills!',
maxPlayers: 8,
difficulty: 'Beginner',
chips: ['👥 Multiplayer', '⚡ Fast-Paced', '🧠 Mental Math'],
color: 'purple',
gradient: 'linear-gradient(135deg, #ddd6fe, #c4b5fd)',
borderColor: 'purple.200',
available: true,
}
const defaultConfig: MathSprintConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
manifest,
Provider: MathSprintProvider,
GameComponent,
validator: mathSprintValidator,
defaultConfig,
})

View File

@@ -0,0 +1,120 @@
/**
* Math Sprint Game Types
*
* A free-for-all game where players race to solve math problems.
* Demonstrates the TEAM_MOVE pattern (no specific turn owner).
*/
import type { GameConfig, GameMove, GameState } from '@/lib/arcade/game-sdk'
/**
* Difficulty levels for math problems
*/
export type Difficulty = 'easy' | 'medium' | 'hard'
/**
* Math operation types
*/
export type Operation = 'addition' | 'subtraction' | 'multiplication'
/**
* Game configuration (persisted to database)
*/
export interface MathSprintConfig extends GameConfig {
difficulty: Difficulty
questionsPerRound: number
timePerQuestion: number // seconds
}
/**
* A math question
*/
export interface Question {
id: string
operand1: number
operand2: number
operation: Operation
correctAnswer: number
displayText: string // e.g., "5 + 3 = ?"
}
/**
* Player answer submission
*/
export interface Answer {
playerId: string
answer: number
timestamp: number
correct: boolean
}
/**
* Game state (synchronized across all clients)
*/
export interface MathSprintState extends GameState {
gamePhase: 'setup' | 'playing' | 'results'
activePlayers: string[]
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
// Configuration
difficulty: Difficulty
questionsPerRound: number
timePerQuestion: number
// Game progress
currentQuestionIndex: number
questions: Question[]
// Scoring
scores: Record<string, number> // playerId -> score
correctAnswersCount: Record<string, number> // playerId -> count
// Current question state
answers: Answer[] // All answers for current question
questionStartTime: number // Timestamp when question was shown
questionAnswered: boolean // True if someone got it right
winnerId: string | null // Winner of current question (first correct)
}
/**
* Move types for Math Sprint
*/
export type MathSprintMove =
| StartGameMove
| SubmitAnswerMove
| NextQuestionMove
| ResetGameMove
| SetConfigMove
export interface StartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: string[]
playerMetadata: Record<string, unknown>
}
}
export interface SubmitAnswerMove extends GameMove {
type: 'SUBMIT_ANSWER'
data: {
answer: number
}
}
export interface NextQuestionMove extends GameMove {
type: 'NEXT_QUESTION'
data: Record<string, never>
}
export interface ResetGameMove extends GameMove {
type: 'RESET_GAME'
data: Record<string, never>
}
export interface SetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'difficulty' | 'questionsPerRound' | 'timePerQuestion'
value: Difficulty | number
}
}