Compare commits

...

6 Commits

Author SHA1 Message Date
semantic-release-bot
c93a1b3074 chore(release): 4.9.0 [skip ci]
## [4.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.8.0...v4.9.0) (2025-10-18)

### Features

* **card-sorting:** implement Provider with arcade session integration ([7f6fea9](7f6fea91f6))
2025-10-18 19:13:00 +00:00
Thomas Hallock
7f6fea91f6 feat(card-sorting): implement Provider with arcade session integration
Add complete Provider implementation for Card Sorting Challenge game.

Features:
- Full useArcadeSession integration with optimistic updates
- Config persistence to database (gameConfig.card-sorting)
- Single-player pattern (local player selection)
- Pause/resume support with config change detection
- Action creators for all 8 move types:
  * startGame() - generates random cards
  * placeCard(cardId, position) - place card in slot
  * removeCard(position) - return card to available
  * checkSolution() - validate answer & calculate score
  * revealNumbers() - show numeric values
  * goToSetup() - pause/return to setup
  * resumeGame() - restore paused game
  * setConfig() - update game settings
- Computed values: canCheckSolution, placedCount, elapsedTime
- Local UI state: selectedCardId (not synced)
- useCardSorting() hook for component access

Next: UI components (Setup, Playing, Results)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 14:11:52 -05:00
semantic-release-bot
aaa253bde0 chore(release): 4.8.0 [skip ci]
## [4.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.1...v4.8.0) (2025-10-18)

### Features

* **arcade:** add Card Sorting Challenge game scaffolding ([df37260](df37260e26))
2025-10-18 19:05:53 +00:00
Thomas Hallock
df37260e26 feat(arcade): add Card Sorting Challenge game scaffolding
Add complete scaffolding for single-player card sorting pattern recognition game.

Architecture:
- Type-safe game config, state, and move definitions
- Server-side validator with 8 move types (START_GAME, PLACE_CARD, REMOVE_CARD, etc.)
- LCS-based scoring algorithm (50% relative order + 30% exact + 20% inversions)
- Card generation using AbacusReact SSR
- Array compaction logic for gap-free card placement

Features:
- Variable difficulty (5, 8, 12, 15 cards)
- Optional number reveal
- Pause/resume support
- Comprehensive score breakdown

Next: Implement Provider and UI components

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 14:04:39 -05:00
semantic-release-bot
b77ff78cfc chore(release): 4.7.1 [skip ci]
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)

### Code Refactoring

* **arcade:** remove number-guesser and math-sprint games ([9fb9786](9fb9786e54))
2025-10-18 17:20:44 +00:00
Thomas Hallock
9fb9786e54 refactor(arcade): remove number-guesser and math-sprint games
Cleaned up arcade room game selection by removing two games:
- Removed number-guesser game and all its code
- Removed math-sprint game and all its code

Changes:
- Removed from game registry (game-registry.ts)
- Removed from validator registry (validators.ts)
- Removed type definitions and default configs (game-configs.ts)
- Deleted all game directories and files

Remaining games:
- Memory Quiz (Soroban Lightning)
- Matching (Memory Pairs Battle)
- Complement Race (Speed Complements)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-18 12:19:45 -05:00
37 changed files with 3300 additions and 3407 deletions

View File

@@ -1,3 +1,24 @@
## [4.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.8.0...v4.9.0) (2025-10-18)
### Features
* **card-sorting:** implement Provider with arcade session integration ([7f6fea9](https://github.com/antialias/soroban-abacus-flashcards/commit/7f6fea91f6dcc69a173eea86bcefc9921f1c1664))
## [4.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.1...v4.8.0) (2025-10-18)
### Features
* **arcade:** add Card Sorting Challenge game scaffolding ([df37260](https://github.com/antialias/soroban-abacus-flashcards/commit/df37260e26bbb146493e0834e093afd98fa3f2a4))
## [4.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.7.0...v4.7.1) (2025-10-18)
### Code Refactoring
* **arcade:** remove number-guesser and math-sprint games ([9fb9786](https://github.com/antialias/soroban-abacus-flashcards/commit/9fb9786e54928e81ecf226b36d343a73143fb674))
## [4.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.6.10...v4.7.0) (2025-10-18)

View File

@@ -94,7 +94,10 @@
"Bash(! echo \"$file\")",
"Bash(then sed -i '' \"s|from ''''../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" sed -i '' \"s|from ''''../../context/ComplementRaceContext''''|from ''''@/arcade-games/complement-race/Provider''''|g\" \"$file\" fi done)",
"Bash(pnpm install)",
"Bash(pnpm exec turbo build --filter=@soroban/web)"
"Bash(pnpm exec turbo build --filter=@soroban/web)",
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')"
],
"deny": [],
"ask": []

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
'use client'
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
createContext,
useContext,
useState,
} from 'react'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { buildPlayerMetadata as buildPlayerMetadataUtil } from '@/lib/arcade/player-ownership.client'
import type { GameMove } from '@/lib/arcade/validation'
import { useGameMode } from '@/contexts/GameModeContext'
import { generateRandomCards, shuffleCards } from './utils/cardGeneration'
import type {
CardSortingState,
CardSortingMove,
SortingCard,
CardSortingConfig,
} from './types'
// Context value interface
interface CardSortingContextValue {
state: CardSortingState
// Actions
startGame: () => void
placeCard: (cardId: string, position: number) => void
removeCard: (position: number) => void
checkSolution: () => void
revealNumbers: () => void
goToSetup: () => void
resumeGame: () => void
setConfig: (
field: 'cardCount' | 'showNumbers' | 'timeLimit',
value: unknown,
) => void
exitSession: () => void
// Computed
canCheckSolution: boolean
placedCount: number
elapsedTime: number
hasConfigChanged: boolean
canResumeGame: boolean
// UI state
selectedCardId: string | null
selectCard: (cardId: string | null) => void
}
// Create context
const CardSortingContext = createContext<CardSortingContextValue | null>(null)
// Initial state matching validator's getInitialState
const createInitialState = (config: Partial<CardSortingConfig>): CardSortingState => ({
cardCount: config.cardCount ?? 8,
showNumbers: config.showNumbers ?? true,
timeLimit: config.timeLimit ?? null,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
id: '',
name: '',
emoji: '',
userId: '',
},
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount ?? 8).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
})
/**
* Optimistic move application (client-side prediction)
*/
function applyMoveOptimistically(
state: CardSortingState,
move: GameMove,
): CardSortingState {
const typedMove = move as CardSortingMove
switch (typedMove.type) {
case 'START_GAME': {
const selectedCards = typedMove.data.selectedCards as SortingCard[]
const correctOrder = [...selectedCards].sort((a, b) => a.number - b.number)
return {
...state,
gamePhase: 'playing',
playerId: typedMove.playerId,
playerMetadata: typedMove.data.playerMetadata,
gameStartTime: Date.now(),
selectedCards,
correctOrder,
availableCards: shuffleCards(selectedCards),
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
// Save original config for pause/resume
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
},
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
case 'PLACE_CARD': {
const { cardId, position } = typedMove.data
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) return state
// Simple insert logic (server will do proper compaction)
const newPlaced = [...state.placedCards]
newPlaced[position] = card
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
case 'REMOVE_CARD': {
const { position } = typedMove.data
const card = state.placedCards[position]
if (!card) return state
const newPlaced = [...state.placedCards]
newPlaced[position] = null
const newAvailable = [...state.availableCards, card]
return {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
}
}
case 'REVEAL_NUMBERS': {
return {
...state,
numbersRevealed: true,
}
}
case 'CHECK_SOLUTION': {
// Server will calculate score - just transition to results optimistically
return {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
}
}
case 'GO_TO_SETUP': {
const isPausingGame = state.gamePhase === 'playing'
return {
...createInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
// Save paused state if coming from active game
originalConfig: state.originalConfig,
pausedGamePhase: isPausingGame ? 'playing' : undefined,
pausedGameState: isPausingGame
? {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
}
: undefined,
}
}
case 'SET_CONFIG': {
const { field, value } = typedMove.data
const clearPausedGame = !!state.pausedGamePhase
return {
...state,
[field]: value,
// Update placedCards array size if cardCount changes
...(field === 'cardCount'
? { placedCards: new Array(value as number).fill(null) }
: {}),
// Clear paused game if config changed
...(clearPausedGame
? {
pausedGamePhase: undefined,
pausedGameState: undefined,
originalConfig: undefined,
}
: {}),
}
}
case 'RESUME_GAME': {
if (!state.pausedGamePhase || !state.pausedGameState) {
return state
}
const correctOrder = [...state.pausedGameState.selectedCards].sort(
(a, b) => a.number - b.number,
)
return {
...state,
gamePhase: state.pausedGamePhase,
selectedCards: state.pausedGameState.selectedCards,
correctOrder,
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
}
}
default:
return state
}
}
/**
* Card Sorting Provider - Single Player Pattern Recognition Game
*/
export function CardSortingProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers, players } = useGameMode()
const { mutate: updateGameConfig } = useUpdateGameConfig()
// Local UI state (not synced to server)
const [selectedCardId, setSelectedCardId] = useState<string | null>(null)
// Get local player (single player game)
const localPlayerId = useMemo(() => {
return Array.from(activePlayers).find((id) => {
const player = players.get(id)
return player?.isLocal !== false
})
}, [activePlayers, players])
// Merge saved config from room data
const mergedInitialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null
const savedConfig = gameConfig?.['card-sorting'] as
| Partial<CardSortingConfig>
| undefined
return createInitialState(savedConfig || {})
}, [roomData?.gameConfig])
// Arcade session integration
const { state, sendMove, exitSession } = useArcadeSession<CardSortingState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState: mergedInitialState,
applyMove: applyMoveOptimistically,
})
// Build player metadata for the single local player
const buildPlayerMetadata = useCallback(() => {
if (!localPlayerId) {
return {
id: '',
name: '',
emoji: '',
userId: '',
}
}
const playerOwnership: Record<string, string> = {}
if (viewerId) {
playerOwnership[localPlayerId] = viewerId
}
const metadata = buildPlayerMetadataUtil(
[localPlayerId],
playerOwnership,
players,
viewerId ?? undefined,
)
return metadata[localPlayerId] || { id: '', name: '', emoji: '', userId: '' }
}, [localPlayerId, players, viewerId])
// Computed values
const canCheckSolution = useMemo(
() => state.placedCards.every((c) => c !== null),
[state.placedCards],
)
const placedCount = useMemo(
() => state.placedCards.filter((c) => c !== null).length,
[state.placedCards],
)
const elapsedTime = useMemo(() => {
if (!state.gameStartTime) return 0
const now = state.gameEndTime || Date.now()
return Math.floor((now - state.gameStartTime) / 1000)
}, [state.gameStartTime, state.gameEndTime])
const hasConfigChanged = useMemo(() => {
if (!state.originalConfig) return false
return (
state.cardCount !== state.originalConfig.cardCount ||
state.showNumbers !== state.originalConfig.showNumbers ||
state.timeLimit !== state.originalConfig.timeLimit
)
}, [state.cardCount, state.showNumbers, state.timeLimit, state.originalConfig])
const canResumeGame = useMemo(() => {
return !!state.pausedGamePhase && !!state.pausedGameState && !hasConfigChanged
}, [state.pausedGamePhase, state.pausedGameState, hasConfigChanged])
// Action creators
const startGame = useCallback(() => {
if (!localPlayerId) {
console.error('[CardSortingProvider] No local player available')
return
}
const playerMetadata = buildPlayerMetadata()
const selectedCards = generateRandomCards(state.cardCount)
sendMove({
type: 'START_GAME',
playerId: localPlayerId,
userId: viewerId || '',
data: {
playerMetadata,
selectedCards,
},
})
}, [
localPlayerId,
state.cardCount,
buildPlayerMetadata,
sendMove,
viewerId,
])
const placeCard = useCallback(
(cardId: string, position: number) => {
if (!localPlayerId) return
sendMove({
type: 'PLACE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { cardId, position },
})
// Clear selection
setSelectedCardId(null)
},
[localPlayerId, sendMove, viewerId],
)
const removeCard = useCallback(
(position: number) => {
if (!localPlayerId) return
sendMove({
type: 'REMOVE_CARD',
playerId: localPlayerId,
userId: viewerId || '',
data: { position },
})
},
[localPlayerId, sendMove, viewerId],
)
const checkSolution = useCallback(() => {
if (!localPlayerId) return
if (!canCheckSolution) {
console.warn('[CardSortingProvider] Cannot check - not all cards placed')
return
}
sendMove({
type: 'CHECK_SOLUTION',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canCheckSolution, sendMove, viewerId])
const revealNumbers = useCallback(() => {
if (!localPlayerId) return
sendMove({
type: 'REVEAL_NUMBERS',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
const goToSetup = useCallback(() => {
if (!localPlayerId) return
sendMove({
type: 'GO_TO_SETUP',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, sendMove, viewerId])
const resumeGame = useCallback(() => {
if (!localPlayerId || !canResumeGame) {
console.warn(
'[CardSortingProvider] Cannot resume - no paused game or config changed',
)
return
}
sendMove({
type: 'RESUME_GAME',
playerId: localPlayerId,
userId: viewerId || '',
data: {},
})
}, [localPlayerId, canResumeGame, sendMove, viewerId])
const setConfig = useCallback(
(
field: 'cardCount' | 'showNumbers' | 'timeLimit',
value: unknown,
) => {
if (!localPlayerId) return
sendMove({
type: 'SET_CONFIG',
playerId: localPlayerId,
userId: viewerId || '',
data: { field, value },
})
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<
string,
unknown
>) || {}
const currentCardSortingConfig =
(currentGameConfig['card-sorting'] as Record<string, unknown>) || {}
const updatedConfig = {
...currentGameConfig,
'card-sorting': {
...currentCardSortingConfig,
[field]: value,
},
}
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[localPlayerId, sendMove, viewerId, roomData, updateGameConfig],
)
const contextValue: CardSortingContextValue = {
state,
// Actions
startGame,
placeCard,
removeCard,
checkSolution,
revealNumbers,
goToSetup,
resumeGame,
setConfig,
exitSession,
// Computed
canCheckSolution,
placedCount,
elapsedTime,
hasConfigChanged,
canResumeGame,
// UI state
selectedCardId,
selectCard: setSelectedCardId,
}
return (
<CardSortingContext.Provider value={contextValue}>
{children}
</CardSortingContext.Provider>
)
}
/**
* Hook to access Card Sorting context
*/
export function useCardSorting() {
const context = useContext(CardSortingContext)
if (!context) {
throw new Error(
'useCardSorting must be used within CardSortingProvider',
)
}
return context
}

View File

@@ -0,0 +1,425 @@
import type { GameValidator, ValidationContext, ValidationResult } from '@/lib/arcade/validation/types'
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
import { calculateScore } from './utils/scoringAlgorithm'
import { placeCardAtPosition, removeCardAtPosition } from './utils/validation'
export class CardSortingValidator
implements GameValidator<CardSortingState, CardSortingMove>
{
validateMove(
state: CardSortingState,
move: CardSortingMove,
context: ValidationContext,
): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data, move.playerId)
case 'PLACE_CARD':
return this.validatePlaceCard(
state,
move.data.cardId,
move.data.position,
)
case 'REMOVE_CARD':
return this.validateRemoveCard(state, move.data.position)
case 'REVEAL_NUMBERS':
return this.validateRevealNumbers(state)
case 'CHECK_SOLUTION':
return this.validateCheckSolution(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
return this.validateSetConfig(state, move.data.field, move.data.value)
case 'RESUME_GAME':
return this.validateResumeGame(state)
default:
return {
valid: false,
error: `Unknown move type: ${(move as CardSortingMove).type}`,
}
}
}
private validateStartGame(
state: CardSortingState,
data: { playerMetadata: unknown; selectedCards: unknown },
playerId: string,
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return {
valid: false,
error: 'Can only start game from setup phase',
}
}
// Validate selectedCards
if (!Array.isArray(data.selectedCards)) {
return { valid: false, error: 'selectedCards must be an array' }
}
if (data.selectedCards.length !== state.cardCount) {
return {
valid: false,
error: `Must provide exactly ${state.cardCount} cards`,
}
}
const selectedCards = data.selectedCards as unknown[]
// Create correct order (sorted)
const correctOrder = [...selectedCards].sort((a: unknown, b: unknown) => {
const cardA = a as { number: number }
const cardB = b as { number: number }
return cardA.number - cardB.number
})
return {
valid: true,
newState: {
...state,
gamePhase: 'playing',
playerId,
playerMetadata: data.playerMetadata,
gameStartTime: Date.now(),
selectedCards: selectedCards as typeof state.selectedCards,
correctOrder: correctOrder as typeof state.correctOrder,
availableCards: selectedCards as typeof state.availableCards,
placedCards: new Array(state.cardCount).fill(null),
numbersRevealed: false,
},
}
}
private validatePlaceCard(
state: CardSortingState,
cardId: string,
position: number,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return { valid: false, error: 'Can only place cards during playing phase' }
}
// Card must exist in availableCards
const card = state.availableCards.find((c) => c.id === cardId)
if (!card) {
return { valid: false, error: 'Card not found in available cards' }
}
// Position must be valid (0 to cardCount-1)
if (position < 0 || position >= state.cardCount) {
return {
valid: false,
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
}
}
// Place the card using utility function
const { placedCards: newPlaced } = placeCardAtPosition(
state.placedCards,
card,
position,
state.cardCount,
)
// Remove from available
const newAvailable = state.availableCards.filter((c) => c.id !== cardId)
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
private validateRemoveCard(
state: CardSortingState,
position: number,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only remove cards during playing phase',
}
}
// Position must be valid
if (position < 0 || position >= state.cardCount) {
return {
valid: false,
error: `Invalid position: must be between 0 and ${state.cardCount - 1}`,
}
}
// Card must exist at position
if (state.placedCards[position] === null) {
return { valid: false, error: 'No card at this position' }
}
// Remove the card using utility function
const { placedCards: newPlaced, removedCard } = removeCardAtPosition(
state.placedCards,
position,
)
if (!removedCard) {
return { valid: false, error: 'Failed to remove card' }
}
// Add back to available
const newAvailable = [...state.availableCards, removedCard]
return {
valid: true,
newState: {
...state,
availableCards: newAvailable,
placedCards: newPlaced,
},
}
}
private validateRevealNumbers(
state: CardSortingState,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only reveal numbers during playing phase',
}
}
// Must be enabled in config
if (!state.showNumbers) {
return { valid: false, error: 'Reveal numbers is not enabled' }
}
// Already revealed
if (state.numbersRevealed) {
return { valid: false, error: 'Numbers already revealed' }
}
return {
valid: true,
newState: {
...state,
numbersRevealed: true,
},
}
}
private validateCheckSolution(
state: CardSortingState,
): ValidationResult {
// Must be in playing phase
if (state.gamePhase !== 'playing') {
return {
valid: false,
error: 'Can only check solution during playing phase',
}
}
// All slots must be filled
if (state.placedCards.some((c) => c === null)) {
return { valid: false, error: 'Must place all cards before checking' }
}
// Calculate score using scoring algorithms
const userSequence = state.placedCards.map((c) => c!.number)
const correctSequence = state.correctOrder.map((c) => c.number)
const scoreBreakdown = calculateScore(
userSequence,
correctSequence,
state.gameStartTime || Date.now(),
state.numbersRevealed,
)
return {
valid: true,
newState: {
...state,
gamePhase: 'results',
gameEndTime: Date.now(),
scoreBreakdown,
},
}
}
private validateGoToSetup(
state: CardSortingState,
): ValidationResult {
// Save current game state for resume (if in playing phase)
if (state.gamePhase === 'playing') {
return {
valid: true,
newState: {
...this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
originalConfig: {
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
},
pausedGamePhase: 'playing',
pausedGameState: {
selectedCards: state.selectedCards,
availableCards: state.availableCards,
placedCards: state.placedCards,
gameStartTime: state.gameStartTime || Date.now(),
numbersRevealed: state.numbersRevealed,
},
},
}
}
// Just go to setup
return {
valid: true,
newState: this.getInitialState({
cardCount: state.cardCount,
showNumbers: state.showNumbers,
timeLimit: state.timeLimit,
}),
}
}
private validateSetConfig(
state: CardSortingState,
field: string,
value: unknown,
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change config in setup phase' }
}
// Validate field and value
switch (field) {
case 'cardCount':
if (![5, 8, 12, 15].includes(value as number)) {
return { valid: false, error: 'cardCount must be 5, 8, 12, or 15' }
}
return {
valid: true,
newState: {
...state,
cardCount: value as 5 | 8 | 12 | 15,
placedCards: new Array(value as number).fill(null),
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'showNumbers':
if (typeof value !== 'boolean') {
return { valid: false, error: 'showNumbers must be a boolean' }
}
return {
valid: true,
newState: {
...state,
showNumbers: value,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
case 'timeLimit':
if (value !== null && (typeof value !== 'number' || value < 30)) {
return {
valid: false,
error: 'timeLimit must be null or a number >= 30',
}
}
return {
valid: true,
newState: {
...state,
timeLimit: value as number | null,
// Clear pause state if config changed
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
}
private validateResumeGame(
state: CardSortingState,
): ValidationResult {
// Must be in setup phase
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only resume from setup phase' }
}
// Must have paused game state
if (!state.pausedGamePhase || !state.pausedGameState) {
return { valid: false, error: 'No paused game to resume' }
}
// Restore paused state
return {
valid: true,
newState: {
...state,
gamePhase: state.pausedGamePhase,
selectedCards: state.pausedGameState.selectedCards,
correctOrder: [...state.pausedGameState.selectedCards].sort(
(a, b) => a.number - b.number,
),
availableCards: state.pausedGameState.availableCards,
placedCards: state.pausedGameState.placedCards,
gameStartTime: state.pausedGameState.gameStartTime,
numbersRevealed: state.pausedGameState.numbersRevealed,
pausedGamePhase: undefined,
pausedGameState: undefined,
},
}
}
isGameComplete(state: CardSortingState): boolean {
return state.gamePhase === 'results'
}
getInitialState(config: CardSortingConfig): CardSortingState {
return {
cardCount: config.cardCount,
showNumbers: config.showNumbers,
timeLimit: config.timeLimit,
gamePhase: 'setup',
playerId: '',
playerMetadata: {
id: '',
name: '',
emoji: '',
userId: '',
},
gameStartTime: null,
gameEndTime: null,
selectedCards: [],
correctOrder: [],
availableCards: [],
placedCards: new Array(config.cardCount).fill(null),
selectedCardId: null,
numbersRevealed: false,
scoreBreakdown: null,
}
}
}
export const cardSortingValidator = new CardSortingValidator()

View File

@@ -0,0 +1,14 @@
'use client'
/**
* Card Sorting Game Component
* TODO: Implement phase routing (setup, playing, results)
*/
export function GameComponent() {
return (
<div>
<h2>Card Sorting Challenge</h2>
<p>Coming soon...</p>
</div>
)
}

View File

@@ -0,0 +1,83 @@
/**
* Card Sorting Challenge Game Definition
*
* A single-player pattern recognition game where players arrange abacus cards
* in ascending order using only visual patterns (no numbers shown).
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { CardSortingProvider } from './Provider'
import type { CardSortingConfig, CardSortingMove, CardSortingState } from './types'
import { cardSortingValidator } from './Validator'
const manifest: GameManifest = {
name: 'card-sorting',
displayName: 'Card Sorting Challenge',
icon: '🔢',
description: 'Sort abacus cards using pattern recognition',
longDescription:
'Challenge your abacus reading skills! Arrange cards in ascending order using only ' +
'the visual patterns - no numbers shown. Perfect for practicing number recognition and ' +
'developing mental math intuition.',
maxPlayers: 1, // Single player only
difficulty: 'Intermediate',
chips: ['🧠 Pattern Recognition', '🎯 Solo Challenge', '📊 Smart Scoring'],
color: 'teal',
gradient: 'linear-gradient(135deg, #99f6e4, #5eead4)',
borderColor: 'teal.200',
available: true,
}
const defaultConfig: CardSortingConfig = {
cardCount: 8,
showNumbers: true,
timeLimit: null,
}
// Config validation function
function validateCardSortingConfig(
config: unknown,
): config is CardSortingConfig {
if (typeof config !== 'object' || config === null) {
return false
}
const c = config as Record<string, unknown>
// Validate cardCount
if (!('cardCount' in c) || ![5, 8, 12, 15].includes(c.cardCount as number)) {
return false
}
// Validate showNumbers
if (!('showNumbers' in c) || typeof c.showNumbers !== 'boolean') {
return false
}
// Validate timeLimit
if ('timeLimit' in c) {
if (
c.timeLimit !== null &&
(typeof c.timeLimit !== 'number' || c.timeLimit < 30)
) {
return false
}
}
return true
}
export const cardSortingGame = defineGame<
CardSortingConfig,
CardSortingState,
CardSortingMove
>({
manifest,
Provider: CardSortingProvider,
GameComponent,
validator: cardSortingValidator,
defaultConfig,
validateConfig: validateCardSortingConfig,
})

View File

@@ -0,0 +1,198 @@
import type { GameConfig, GameState } from '@/lib/arcade/game-sdk/types'
// ============================================================================
// Player Metadata
// ============================================================================
export interface PlayerMetadata {
id: string // Player ID (UUID)
name: string
emoji: string
userId: string
}
// ============================================================================
// Configuration
// ============================================================================
export interface CardSortingConfig extends GameConfig {
cardCount: 5 | 8 | 12 | 15 // Difficulty (number of cards)
showNumbers: boolean // Allow reveal numbers button
timeLimit: number | null // Optional time limit (seconds), null = unlimited
}
// ============================================================================
// Core Data Types
// ============================================================================
export type GamePhase = 'setup' | 'playing' | 'results'
export interface SortingCard {
id: string // Unique ID for this card instance
number: number // The abacus value (0-99+)
svgContent: string // Serialized AbacusReact SVG
}
export interface PlacedCard {
card: SortingCard // The card data
position: number // Which slot it's in (0-indexed)
}
export interface ScoreBreakdown {
finalScore: number // 0-100 weighted average
exactMatches: number // Cards in exactly correct position
lcsLength: number // Longest common subsequence length
inversions: number // Number of out-of-order pairs
relativeOrderScore: number // 0-100 based on LCS
exactPositionScore: number // 0-100 based on exact matches
inversionScore: number // 0-100 based on inversions
elapsedTime: number // Seconds taken
numbersRevealed: boolean // Whether player used reveal
}
// ============================================================================
// Game State
// ============================================================================
export interface CardSortingState extends GameState {
// Configuration
cardCount: 5 | 8 | 12 | 15
showNumbers: boolean
timeLimit: number | null
// Game phase
gamePhase: GamePhase
// Player & timing
playerId: string // Single player ID
playerMetadata: PlayerMetadata // Player display info
gameStartTime: number | null
gameEndTime: number | null
// Cards
selectedCards: SortingCard[] // The N cards for this game
correctOrder: SortingCard[] // Sorted by number (answer key)
availableCards: SortingCard[] // Cards not yet placed
placedCards: (SortingCard | null)[] // Array of N slots (null = empty)
// UI state (client-only, not in server state)
selectedCardId: string | null // Currently selected card
numbersRevealed: boolean // If player revealed numbers
// Results
scoreBreakdown: ScoreBreakdown | null // Final score details
// Pause/Resume (standard pattern)
originalConfig?: CardSortingConfig
pausedGamePhase?: GamePhase
pausedGameState?: {
selectedCards: SortingCard[]
availableCards: SortingCard[]
placedCards: (SortingCard | null)[]
gameStartTime: number
numbersRevealed: boolean
}
}
// ============================================================================
// Game Moves
// ============================================================================
export type CardSortingMove =
| {
type: 'START_GAME'
playerId: string
userId: string
timestamp: number
data: {
playerMetadata: PlayerMetadata
selectedCards: SortingCard[] // Pre-selected random cards
}
}
| {
type: 'PLACE_CARD'
playerId: string
userId: string
timestamp: number
data: {
cardId: string // Which card to place
position: number // Which slot (0-indexed)
}
}
| {
type: 'REMOVE_CARD'
playerId: string
userId: string
timestamp: number
data: {
position: number // Which slot to remove from
}
}
| {
type: 'REVEAL_NUMBERS'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'CHECK_SOLUTION'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'GO_TO_SETUP'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
| {
type: 'SET_CONFIG'
playerId: string
userId: string
timestamp: number
data: {
field: 'cardCount' | 'showNumbers' | 'timeLimit'
value: unknown
}
}
| {
type: 'RESUME_GAME'
playerId: string
userId: string
timestamp: number
data: Record<string, never>
}
// ============================================================================
// Component Props
// ============================================================================
export interface SortingCardProps {
card: SortingCard
isSelected: boolean
isPlaced: boolean
isCorrect?: boolean // After checking solution
onClick: () => void
showNumber: boolean // If revealed
}
export interface PositionSlotProps {
position: number
card: SortingCard | null
isActive: boolean // If slot is clickable
isCorrect?: boolean // After checking solution
gradientStyle: React.CSSProperties
onClick: () => void
}
export interface ScoreDisplayProps {
breakdown: ScoreBreakdown
correctOrder: SortingCard[]
userOrder: SortingCard[]
onNewGame: () => void
onExit: () => void
}

View File

@@ -0,0 +1,51 @@
import { AbacusReact } from '@soroban/abacus-react'
import { renderToString } from 'react-dom/server'
import type { SortingCard } from '../types'
/**
* Generate random cards for sorting game
* @param count Number of cards to generate
* @param minValue Minimum abacus value (default 0)
* @param maxValue Maximum abacus value (default 99)
*/
export function generateRandomCards(
count: number,
minValue = 0,
maxValue = 99,
): SortingCard[] {
// Generate pool of unique random numbers
const numbers = new Set<number>()
while (numbers.size < count) {
const num = Math.floor(Math.random() * (maxValue - minValue + 1)) + minValue
numbers.add(num)
}
// Convert to sorted array (for answer key)
const sortedNumbers = Array.from(numbers).sort((a, b) => a - b)
// Create card objects with SVG content
return sortedNumbers.map((number, index) => {
// Render AbacusReact to SVG string
const svgContent = renderToString(
<AbacusReact value={number} width={200} height={120} />,
)
return {
id: `card-${index}-${number}`,
number,
svgContent,
}
})
}
/**
* Shuffle array for random order
*/
export function shuffleCards(cards: SortingCard[]): SortingCard[] {
const shuffled = [...cards]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}

View File

@@ -0,0 +1,109 @@
import type { ScoreBreakdown } from '../types'
/**
* Calculate Longest Common Subsequence length
* Measures how many cards are in correct relative order
*/
export function longestCommonSubsequence(
seq1: number[],
seq2: number[],
): number {
const m = seq1.length
const n = seq2.length
const dp: number[][] = Array(m + 1)
.fill(0)
.map(() => Array(n + 1).fill(0))
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (seq1[i - 1] === seq2[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1])
}
}
}
return dp[m][n]
}
/**
* Count inversions (out-of-order pairs)
* Measures how scrambled the sequence is
*/
export function countInversions(
userSeq: number[],
correctSeq: number[],
): number {
// Create mapping from value to correct position
const correctPositions: Record<number, number> = {}
for (let idx = 0; idx < correctSeq.length; idx++) {
correctPositions[correctSeq[idx]] = idx
}
// Convert user sequence to correct-position sequence
const userCorrectPositions = userSeq.map((val) => correctPositions[val])
// Count inversions
let inversions = 0
for (let i = 0; i < userCorrectPositions.length; i++) {
for (let j = i + 1; j < userCorrectPositions.length; j++) {
if (userCorrectPositions[i] > userCorrectPositions[j]) {
inversions++
}
}
}
return inversions
}
/**
* Calculate comprehensive score breakdown
*/
export function calculateScore(
userSequence: number[],
correctSequence: number[],
startTime: number,
numbersRevealed: boolean,
): ScoreBreakdown {
// LCS-based score (relative order)
const lcsLength = longestCommonSubsequence(userSequence, correctSequence)
const relativeOrderScore = (lcsLength / correctSequence.length) * 100
// Exact position matches
let exactMatches = 0
for (let i = 0; i < userSequence.length; i++) {
if (userSequence[i] === correctSequence[i]) {
exactMatches++
}
}
const exactPositionScore = (exactMatches / correctSequence.length) * 100
// Inversion-based score (organization)
const inversions = countInversions(userSequence, correctSequence)
const maxInversions = (correctSequence.length * (correctSequence.length - 1)) / 2
const inversionScore = Math.max(
0,
((maxInversions - inversions) / maxInversions) * 100,
)
// Weighted final score
// - 50% for relative order (LCS)
// - 30% for exact positions
// - 20% for organization (inversions)
const finalScore = Math.round(
relativeOrderScore * 0.5 + exactPositionScore * 0.3 + inversionScore * 0.2,
)
return {
finalScore,
exactMatches,
lcsLength,
inversions,
relativeOrderScore: Math.round(relativeOrderScore),
exactPositionScore: Math.round(exactPositionScore),
inversionScore: Math.round(inversionScore),
elapsedTime: Math.floor((Date.now() - startTime) / 1000),
numbersRevealed,
}
}

View File

@@ -0,0 +1,82 @@
import type { SortingCard } from '../types'
/**
* Place a card at a specific position, shifting existing cards
* Returns new placedCards array with no gaps
*/
export function placeCardAtPosition(
placedCards: (SortingCard | null)[],
cardToPlace: SortingCard,
position: number,
totalSlots: number,
): { placedCards: (SortingCard | null)[]; excessCards: SortingCard[] } {
// Create working array
const newPlaced = new Array(totalSlots).fill(null)
// Copy existing cards, shifting those at/after position
for (let i = 0; i < placedCards.length; i++) {
if (placedCards[i] !== null) {
if (i < position) {
// Before insert position - stays same
newPlaced[i] = placedCards[i]
} else {
// At or after position - shift right
if (i + 1 < totalSlots) {
newPlaced[i + 1] = placedCards[i]
}
}
}
}
// Place new card
newPlaced[position] = cardToPlace
// Compact to remove gaps (shift all cards left)
const compacted: SortingCard[] = []
for (const card of newPlaced) {
if (card !== null) {
compacted.push(card)
}
}
// Fill final array
const result = new Array(totalSlots).fill(null)
for (let i = 0; i < Math.min(compacted.length, totalSlots); i++) {
result[i] = compacted[i]
}
// Any excess cards are returned (shouldn't happen)
const excess = compacted.slice(totalSlots)
return { placedCards: result, excessCards: excess }
}
/**
* Remove card at position
*/
export function removeCardAtPosition(
placedCards: (SortingCard | null)[],
position: number,
): { placedCards: (SortingCard | null)[]; removedCard: SortingCard | null } {
const removedCard = placedCards[position]
if (!removedCard) {
return { placedCards, removedCard: null }
}
// Remove card and compact
const compacted: SortingCard[] = []
for (let i = 0; i < placedCards.length; i++) {
if (i !== position && placedCards[i] !== null) {
compacted.push(placedCards[i] as SortingCard)
}
}
// Fill new array
const newPlaced = new Array(placedCards.length).fill(null)
for (let i = 0; i < compacted.length; i++) {
newPlaced[i] = compacted[i]
}
return { placedCards: newPlaced, removedCard }
}

View File

@@ -1,199 +0,0 @@
/**
* 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

@@ -1,329 +0,0 @@
/**
* 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 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

@@ -1,40 +0,0 @@
/**
* 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

@@ -1,350 +0,0 @@
/**
* 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..."
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

@@ -1,194 +0,0 @@
/**
* 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

@@ -1,198 +0,0 @@
/**
* 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

@@ -1,16 +0,0 @@
name: math-sprint
displayName: Math Sprint
icon: 🧮
description: Fast-paced math racing game
longDescription: Race against other players to solve math problems! Answer questions quickly to earn points. First person to answer correctly wins the round. Features multiple difficulty levels and customizable question counts.
maxPlayers: 6
difficulty: Beginner
chips:
- 👥 Multiplayer
- ⚡ Free-for-All
- 🧮 Math Skills
- 🏃 Speed
color: purple
gradient: linear-gradient(135deg, #ddd6fe, #c4b5fd)
borderColor: purple.200
available: true

View File

@@ -1,61 +0,0 @@
/**
* 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,
}
// Config validation function
function validateMathSprintConfig(config: unknown): config is MathSprintConfig {
return (
typeof config === 'object' &&
config !== null &&
'difficulty' in config &&
'questionsPerRound' in config &&
'timePerQuestion' in config &&
['easy', 'medium', 'hard'].includes((config as any).difficulty) &&
typeof (config as any).questionsPerRound === 'number' &&
typeof (config as any).timePerQuestion === 'number' &&
(config as any).questionsPerRound >= 5 &&
(config as any).questionsPerRound <= 20 &&
(config as any).timePerQuestion >= 10
)
}
export const mathSprintGame = defineGame<MathSprintConfig, MathSprintState, MathSprintMove>({
manifest,
Provider: MathSprintProvider,
GameComponent,
validator: mathSprintValidator,
defaultConfig,
validateConfig: validateMathSprintConfig,
})

View File

@@ -1,120 +0,0 @@
/**
* 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
}
}

View File

@@ -1,215 +0,0 @@
/**
* Number Guesser Provider
* Manages game state using the Arcade SDK
*/
'use client'
import { createContext, useCallback, useContext, useMemo, type ReactNode } from 'react'
import {
type GameMove,
buildPlayerMetadata,
useArcadeSession,
useGameMode,
useRoomData,
useUpdateGameConfig,
useViewerId,
} from '@/lib/arcade/game-sdk'
import type { NumberGuesserState } from './types'
/**
* Context value interface
*/
interface NumberGuesserContextValue {
state: NumberGuesserState
lastError: string | null
startGame: () => void
chooseNumber: (number: number) => void
makeGuess: (guess: number) => void
nextRound: () => void
goToSetup: () => void
setConfig: (field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => void
clearError: () => void
exitSession: () => void
}
const NumberGuesserContext = createContext<NumberGuesserContextValue | null>(null)
/**
* Hook to access Number Guesser context
*/
export function useNumberGuesser() {
const context = useContext(NumberGuesserContext)
if (!context) {
throw new Error('useNumberGuesser must be used within NumberGuesserProvider')
}
return context
}
/**
* Optimistic move application
*/
function applyMoveOptimistically(state: NumberGuesserState, move: GameMove): NumberGuesserState {
// For simplicity, just return current state
// Server will send back the validated new state
return state
}
/**
* Number Guesser Provider Component
*/
export function NumberGuesserProvider({ 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 to match UI display)
const activePlayers = Array.from(activePlayerIds)
// Merge saved config from room
const initialState = useMemo(() => {
const gameConfig = roomData?.gameConfig as Record<string, unknown> | null | undefined
const savedConfig = gameConfig?.['number-guesser'] as Record<string, unknown> | undefined
return {
minNumber: (savedConfig?.minNumber as number) || 1,
maxNumber: (savedConfig?.maxNumber as number) || 100,
roundsToWin: (savedConfig?.roundsToWin as number) || 3,
gamePhase: 'setup' as const,
activePlayers: [],
playerMetadata: {},
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
}, [roomData?.gameConfig])
// Arcade session integration
const { state, sendMove, exitSession, lastError, clearError } =
useArcadeSession<NumberGuesserState>({
userId: viewerId || '',
roomId: roomData?.id,
initialState,
applyMove: applyMoveOptimistically,
})
// Action creators
const startGame = useCallback(() => {
if (activePlayers.length < 2) {
console.error('Need at least 2 players to start')
return
}
const playerMetadata = buildPlayerMetadata(activePlayers, {}, players, viewerId || undefined)
sendMove({
type: 'START_GAME',
playerId: activePlayers[0],
userId: viewerId || '',
data: {
activePlayers,
playerMetadata,
},
})
}, [activePlayers, players, viewerId, sendMove])
const chooseNumber = useCallback(
(secretNumber: number) => {
sendMove({
type: 'CHOOSE_NUMBER',
playerId: state.chooser,
userId: viewerId || '',
data: { secretNumber },
})
},
[state.chooser, viewerId, sendMove]
)
const makeGuess = useCallback(
(guess: number) => {
const playerName = state.playerMetadata[state.currentGuesser]?.name || 'Unknown'
sendMove({
type: 'MAKE_GUESS',
playerId: state.currentGuesser,
userId: viewerId || '',
data: { guess, playerName },
})
},
[state.currentGuesser, state.playerMetadata, viewerId, sendMove]
)
const nextRound = useCallback(() => {
sendMove({
type: 'NEXT_ROUND',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: {},
})
}, [activePlayers, viewerId, sendMove])
const goToSetup = useCallback(() => {
sendMove({
type: 'GO_TO_SETUP',
playerId: activePlayers[0] || state.chooser || '',
userId: viewerId || '',
data: {},
})
}, [activePlayers, state.chooser, viewerId, sendMove])
const setConfig = useCallback(
(field: 'minNumber' | 'maxNumber' | 'roundsToWin', value: number) => {
sendMove({
type: 'SET_CONFIG',
playerId: activePlayers[0] || '',
userId: viewerId || '',
data: { field, value },
})
// Persist to database
if (roomData?.id) {
const currentGameConfig = (roomData.gameConfig as Record<string, unknown>) || {}
const currentNumberGuesserConfig =
(currentGameConfig['number-guesser'] as Record<string, unknown>) || {}
const updatedConfig = {
...currentGameConfig,
'number-guesser': {
...currentNumberGuesserConfig,
[field]: value,
},
}
updateGameConfig({
roomId: roomData.id,
gameConfig: updatedConfig,
})
}
},
[activePlayers, viewerId, sendMove, roomData?.id, roomData?.gameConfig, updateGameConfig]
)
const contextValue: NumberGuesserContextValue = {
state,
lastError,
startGame,
chooseNumber,
makeGuess,
nextRound,
goToSetup,
setConfig,
clearError,
exitSession,
}
return (
<NumberGuesserContext.Provider value={contextValue}>{children}</NumberGuesserContext.Provider>
)
}

View File

@@ -1,315 +0,0 @@
/**
* Server-side validator for Number Guesser game
*/
import type { GameValidator, ValidationResult } from '@/lib/arcade/game-sdk'
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
export class NumberGuesserValidator
implements GameValidator<NumberGuesserState, NumberGuesserMove>
{
validateMove(state: NumberGuesserState, move: NumberGuesserMove): ValidationResult {
switch (move.type) {
case 'START_GAME':
return this.validateStartGame(state, move.data.activePlayers, move.data.playerMetadata)
case 'CHOOSE_NUMBER':
// Ensure secretNumber is a number (JSON deserialization can make it a string)
return this.validateChooseNumber(state, Number(move.data.secretNumber), move.playerId)
case 'MAKE_GUESS':
// Ensure guess is a number (JSON deserialization can make it a string)
return this.validateMakeGuess(
state,
Number(move.data.guess),
move.playerId,
move.data.playerName
)
case 'NEXT_ROUND':
return this.validateNextRound(state)
case 'GO_TO_SETUP':
return this.validateGoToSetup(state)
case 'SET_CONFIG':
// Ensure value is a number (JSON deserialization can make it a string)
return this.validateSetConfig(state, move.data.field, Number(move.data.value))
default:
return {
valid: false,
error: `Unknown move type: ${(move as { type: string }).type}`,
}
}
}
private validateStartGame(
state: NumberGuesserState,
activePlayers: string[],
playerMetadata: Record<string, unknown>
): ValidationResult {
if (!activePlayers || activePlayers.length < 2) {
return { valid: false, error: 'Need at least 2 players' }
}
const newState: NumberGuesserState = {
...state,
gamePhase: 'choosing',
activePlayers,
playerMetadata: playerMetadata as typeof state.playerMetadata,
chooser: activePlayers[0],
currentGuesser: '',
secretNumber: null,
guesses: [],
roundNumber: 1,
scores: activePlayers.reduce((acc, p) => ({ ...acc, [p]: 0 }), {}),
gameStartTime: Date.now(),
gameEndTime: null,
winner: null,
}
return { valid: true, newState }
}
private validateChooseNumber(
state: NumberGuesserState,
secretNumber: number,
playerId: string
): ValidationResult {
if (state.gamePhase !== 'choosing') {
return { valid: false, error: 'Not in choosing phase' }
}
if (playerId !== state.chooser) {
return { valid: false, error: 'Not your turn to choose' }
}
if (
secretNumber < state.minNumber ||
secretNumber > state.maxNumber ||
!Number.isInteger(secretNumber)
) {
return {
valid: false,
error: `Number must be between ${state.minNumber} and ${state.maxNumber}`,
}
}
// Debug logging
console.log('[NumberGuesser] Setting secret number:', {
secretNumber,
secretNumberType: typeof secretNumber,
})
// First guesser is the next player after chooser
const chooserIndex = state.activePlayers.indexOf(state.chooser)
const firstGuesserIndex = (chooserIndex + 1) % state.activePlayers.length
const firstGuesser = state.activePlayers[firstGuesserIndex]
const newState: NumberGuesserState = {
...state,
gamePhase: 'guessing',
secretNumber,
currentGuesser: firstGuesser,
}
return { valid: true, newState }
}
private validateMakeGuess(
state: NumberGuesserState,
guess: number,
playerId: string,
playerName: string
): ValidationResult {
if (state.gamePhase !== 'guessing') {
return { valid: false, error: 'Not in guessing phase' }
}
if (playerId !== state.currentGuesser) {
return { valid: false, error: 'Not your turn to guess' }
}
if (guess < state.minNumber || guess > state.maxNumber || !Number.isInteger(guess)) {
return {
valid: false,
error: `Guess must be between ${state.minNumber} and ${state.maxNumber}`,
}
}
if (!state.secretNumber) {
return { valid: false, error: 'No secret number set' }
}
// Debug logging
console.log('[NumberGuesser] Validating guess:', {
guess,
guessType: typeof guess,
secretNumber: state.secretNumber,
secretNumberType: typeof state.secretNumber,
})
const distance = Math.abs(guess - state.secretNumber)
console.log('[NumberGuesser] Calculated distance:', distance)
const newGuess = {
playerId,
playerName,
guess,
distance,
timestamp: Date.now(),
}
const guesses = [...state.guesses, newGuess]
// Check if guess is correct
if (distance === 0) {
// Correct guess! Award point and end round
const newScores = {
...state.scores,
[playerId]: (state.scores[playerId] || 0) + 1,
}
// Check if player won
const winner = newScores[playerId] >= state.roundsToWin ? playerId : null
const newState: NumberGuesserState = {
...state,
guesses,
scores: newScores,
gamePhase: winner ? 'results' : 'guessing',
gameEndTime: winner ? Date.now() : null,
winner,
}
return { valid: true, newState }
}
// Incorrect guess, move to next guesser
const guesserIndex = state.activePlayers.indexOf(state.currentGuesser)
let nextGuesserIndex = (guesserIndex + 1) % state.activePlayers.length
// Skip the chooser
if (state.activePlayers[nextGuesserIndex] === state.chooser) {
nextGuesserIndex = (nextGuesserIndex + 1) % state.activePlayers.length
}
const newState: NumberGuesserState = {
...state,
guesses,
currentGuesser: state.activePlayers[nextGuesserIndex],
}
return { valid: true, newState }
}
private validateNextRound(state: NumberGuesserState): ValidationResult {
if (state.gamePhase !== 'guessing') {
return { valid: false, error: 'Not in guessing phase' }
}
// Check if the round is complete (someone guessed correctly)
const roundComplete =
state.guesses.length > 0 && state.guesses[state.guesses.length - 1].distance === 0
if (!roundComplete) {
return { valid: false, error: 'Round not complete yet - no one has guessed the number' }
}
// Rotate chooser to next player
const chooserIndex = state.activePlayers.indexOf(state.chooser)
const nextChooserIndex = (chooserIndex + 1) % state.activePlayers.length
const nextChooser = state.activePlayers[nextChooserIndex]
const newState: NumberGuesserState = {
...state,
gamePhase: 'choosing',
chooser: nextChooser,
currentGuesser: '',
secretNumber: null,
guesses: [],
roundNumber: state.roundNumber + 1,
winner: null,
}
return { valid: true, newState }
}
private validateGoToSetup(state: NumberGuesserState): ValidationResult {
const newState: NumberGuesserState = {
...state,
gamePhase: 'setup',
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
activePlayers: [],
playerMetadata: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
return { valid: true, newState }
}
private validateSetConfig(
state: NumberGuesserState,
field: 'minNumber' | 'maxNumber' | 'roundsToWin',
value: number
): ValidationResult {
if (state.gamePhase !== 'setup') {
return { valid: false, error: 'Can only change config in setup' }
}
if (!Number.isInteger(value) || value < 1) {
return { valid: false, error: 'Value must be a positive integer' }
}
if (field === 'minNumber' && value >= state.maxNumber) {
return { valid: false, error: 'Min must be less than max' }
}
if (field === 'maxNumber' && value <= state.minNumber) {
return { valid: false, error: 'Max must be greater than min' }
}
const newState: NumberGuesserState = {
...state,
[field]: value,
}
return { valid: true, newState }
}
isGameComplete(state: NumberGuesserState): boolean {
return state.gamePhase === 'results' && state.winner !== null
}
getInitialState(config: unknown): NumberGuesserState {
const { minNumber, maxNumber, roundsToWin } = config as NumberGuesserConfig
return {
minNumber: minNumber || 1,
maxNumber: maxNumber || 100,
roundsToWin: roundsToWin || 3,
gamePhase: 'setup',
activePlayers: [],
playerMetadata: {},
secretNumber: null,
chooser: '',
currentGuesser: '',
guesses: [],
roundNumber: 0,
scores: {},
gameStartTime: null,
gameEndTime: null,
winner: null,
}
}
}
export const numberGuesserValidator = new NumberGuesserValidator()

View File

@@ -1,211 +0,0 @@
/**
* Choosing Phase - Chooser picks a secret number
*/
'use client'
import { useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function ChoosingPhase() {
const { state, chooseNumber } = useNumberGuesser()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const chooserMetadata = state.playerMetadata[state.chooser]
const isChooser = chooserMetadata?.userId === viewerId
const handleSubmit = () => {
const number = Number.parseInt(inputValue, 10)
if (Number.isNaN(number)) return
chooseNumber(number)
}
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
{chooserMetadata?.emoji || '🤔'}
</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
{isChooser ? "You're choosing!" : `${chooserMetadata?.name || 'Someone'} is choosing...`}
</h2>
<p
className={css({
color: 'gray.600',
})}
>
Round {state.roundNumber}
</p>
</div>
{isChooser ? (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
})}
>
<label
className={css({
display: 'block',
fontSize: 'md',
fontWeight: '600',
marginBottom: '12px',
textAlign: 'center',
})}
>
Choose a secret number ({state.minNumber} - {state.maxNumber})
</label>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
min={state.minNumber}
max={state.maxNumber}
placeholder={`${state.minNumber} - ${state.maxNumber}`}
className={css({
width: '100%',
padding: '16px',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
fontSize: 'xl',
textAlign: 'center',
marginBottom: '16px',
})}
/>
<button
onClick={handleSubmit}
disabled={!inputValue}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Confirm Choice
</button>
</div>
) : (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '32px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
</div>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Waiting for {chooserMetadata?.name || 'player'} to choose a number...
</p>
</div>
)}
{/* Scoreboard */}
<div
className={css({
marginTop: '32px',
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
})}
>
Scores
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'center',
})}
>
{state.activePlayers.map((playerId) => {
const player = state.playerMetadata[playerId]
return (
<div
key={playerId}
className={css({
padding: '8px 16px',
background: 'gray.100',
borderRadius: '8px',
fontSize: 'sm',
})}
>
{player?.emoji} {player?.name}: {state.scores[playerId] || 0}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,60 +0,0 @@
/**
* Number Guesser Game Component
* Main component that switches between game phases
*/
'use client'
import { useRouter } from 'next/navigation'
import { PageWithNav } from '@/components/PageWithNav'
import { useNumberGuesser } from '../Provider'
import { ChoosingPhase } from './ChoosingPhase'
import { GuessingPhase } from './GuessingPhase'
import { ResultsPhase } from './ResultsPhase'
import { SetupPhase } from './SetupPhase'
export function GameComponent() {
const router = useRouter()
const { state, exitSession, goToSetup } = useNumberGuesser()
// Determine whose turn it is based on game phase
const currentPlayerId =
state.gamePhase === 'choosing'
? state.chooser
: state.gamePhase === 'guessing'
? state.currentGuesser
: undefined
return (
<PageWithNav
navTitle="Number Guesser"
navEmoji="🎯"
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={currentPlayerId}
playerScores={state.scores}
onExitSession={() => {
exitSession?.()
router.push('/arcade')
}}
onNewGame={() => {
goToSetup?.()
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
minHeight: '100vh',
background: 'linear-gradient(135deg, #fff7ed, #ffedd5)',
}}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'choosing' && <ChoosingPhase />}
{state.gamePhase === 'guessing' && <GuessingPhase />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</PageWithNav>
)
}

View File

@@ -1,445 +0,0 @@
/**
* Guessing Phase - Players take turns guessing the secret number
*/
'use client'
import { useEffect, useState } from 'react'
import { useViewerId } from '@/lib/arcade/game-sdk'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function GuessingPhase() {
const { state, makeGuess, nextRound, lastError, clearError } = useNumberGuesser()
const { data: viewerId } = useViewerId()
const [inputValue, setInputValue] = useState('')
const currentGuesserMetadata = state.playerMetadata[state.currentGuesser]
const isCurrentGuesser = currentGuesserMetadata?.userId === viewerId
// Check if someone just won the round
const lastGuess = state.guesses[state.guesses.length - 1]
const roundJustEnded = lastGuess?.distance === 0
// Auto-clear error after 5 seconds
useEffect(() => {
if (lastError) {
const timeout = setTimeout(() => clearError(), 5000)
return () => clearTimeout(timeout)
}
}, [lastError, clearError])
const handleSubmit = () => {
const guess = Number.parseInt(inputValue, 10)
if (Number.isNaN(guess)) return
makeGuess(guess)
setInputValue('')
}
const getHotColdMessage = (distance: number) => {
if (distance === 0) return '🎯 Correct!'
if (distance <= 5) return '🔥 Very Hot!'
if (distance <= 10) return '🌡️ Hot'
if (distance <= 20) return '😊 Warm'
if (distance <= 30) return '😐 Cool'
if (distance <= 50) return '❄️ Cold'
return '🧊 Very Cold'
}
return (
<div
className={css({
padding: '32px',
maxWidth: '800px',
margin: '0 auto',
})}
>
{/* Header */}
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '64px',
marginBottom: '16px',
})}
>
{roundJustEnded ? '🎉' : currentGuesserMetadata?.emoji || '🤔'}
</div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
{roundJustEnded
? `${lastGuess.playerName} guessed it!`
: isCurrentGuesser
? 'Your turn to guess!'
: `${currentGuesserMetadata?.name || 'Someone'} is guessing...`}
</h2>
<p
className={css({
color: 'gray.600',
})}
>
Round {state.roundNumber} Range: {state.minNumber} - {state.maxNumber}
</p>
</div>
{/* Error Banner */}
{lastError && (
<div
className={css({
background: 'linear-gradient(135deg, #fef2f2, #fee2e2)',
border: '2px solid',
borderColor: 'red.300',
borderRadius: '12px',
padding: '16px 20px',
marginBottom: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'slideIn 0.3s ease',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
<div
className={css({
fontSize: '24px',
})}
>
</div>
<div>
<div
className={css({
fontSize: 'md',
fontWeight: 'bold',
color: 'red.700',
marginBottom: '4px',
})}
>
Move Rejected
</div>
<div
className={css({
fontSize: 'sm',
color: 'red.600',
})}
>
{lastError}
</div>
</div>
</div>
<button
type="button"
onClick={clearError}
className={css({
padding: '8px 12px',
background: 'white',
border: '1px solid',
borderColor: 'red.300',
borderRadius: '6px',
fontSize: 'sm',
fontWeight: '600',
color: 'red.700',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
background: 'red.50',
},
})}
>
Dismiss
</button>
</div>
)}
{/* Round ended - show next round button */}
{roundJustEnded && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'green.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
textAlign: 'center',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
🎯
</div>
<p
className={css({
fontSize: 'lg',
marginBottom: '16px',
})}
>
The secret number was <strong>{state.secretNumber}</strong>!
</p>
<button
type="button"
onClick={nextRound}
className={css({
padding: '12px 24px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'md',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Next Round
</button>
</div>
)}
{/* Guessing input (only if round not ended) */}
{!roundJustEnded && (
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
{isCurrentGuesser ? (
<>
<label
className={css({
display: 'block',
fontSize: 'md',
fontWeight: '600',
marginBottom: '12px',
textAlign: 'center',
})}
>
Make your guess ({state.minNumber} - {state.maxNumber})
</label>
<input
type="number"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue) {
handleSubmit()
}
}}
min={state.minNumber}
max={state.maxNumber}
placeholder={`${state.minNumber} - ${state.maxNumber}`}
className={css({
width: '100%',
padding: '16px',
border: '2px solid',
borderColor: 'gray.300',
borderRadius: '8px',
fontSize: 'xl',
textAlign: 'center',
marginBottom: '16px',
})}
/>
<button
type="button"
onClick={handleSubmit}
disabled={!inputValue}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '8px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_disabled: {
opacity: 0.5,
cursor: 'not-allowed',
},
_hover: {
transform: 'translateY(-2px)',
},
})}
>
Submit Guess
</button>
</>
) : (
<div
className={css({
textAlign: 'center',
padding: '16px',
})}
>
<div
className={css({
fontSize: '48px',
marginBottom: '16px',
})}
>
</div>
<p
className={css({
fontSize: 'lg',
color: 'gray.600',
})}
>
Waiting for {currentGuesserMetadata?.name || 'player'} to guess...
</p>
</div>
)}
</div>
)}
{/* Guess history */}
{state.guesses.length > 0 && (
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
})}
>
Guess History
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '8px',
})}
>
{state.guesses.map((guess, index) => {
const player = state.playerMetadata[guess.playerId]
return (
<div
key={index}
className={css({
padding: '12px',
background: guess.distance === 0 ? 'green.50' : 'gray.50',
borderRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '8px',
})}
>
<span>{player?.emoji || '🎮'}</span>
<span className={css({ fontWeight: '600' })}>{guess.playerName}</span>
<span className={css({ color: 'gray.600' })}>guessed</span>
<span className={css({ fontWeight: 'bold', fontSize: 'lg' })}>
{guess.guess}
</span>
</div>
<div
className={css({
fontWeight: 'bold',
color: guess.distance === 0 ? 'green.700' : 'orange.700',
})}
>
{getHotColdMessage(guess.distance)}
</div>
</div>
)
})}
</div>
</div>
)}
{/* Scoreboard */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '12px',
textAlign: 'center',
})}
>
Scores (First to {state.roundsToWin} wins!)
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
justifyContent: 'center',
})}
>
{state.activePlayers.map((playerId) => {
const player = state.playerMetadata[playerId]
const score = state.scores[playerId] || 0
return (
<div
key={playerId}
className={css({
padding: '8px 16px',
background: 'gray.100',
borderRadius: '8px',
fontSize: 'sm',
})}
>
{player?.emoji} {player?.name}: {score}
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -1,208 +0,0 @@
/**
* Results Phase - Shows winner and final scores
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function ResultsPhase() {
const { state, goToSetup } = useNumberGuesser()
const winnerMetadata = state.winner ? state.playerMetadata[state.winner] : null
const winnerScore = state.winner ? state.scores[state.winner] : 0
// Sort players by score
const sortedPlayers = [...state.activePlayers].sort((a, b) => {
const scoreA = state.scores[a] || 0
const scoreB = state.scores[b] || 0
return scoreB - scoreA
})
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
{/* Winner Celebration */}
<div
className={css({
textAlign: 'center',
marginBottom: '32px',
})}
>
<div
className={css({
fontSize: '96px',
marginBottom: '16px',
animation: 'bounce 1s ease-in-out infinite',
})}
>
{winnerMetadata?.emoji || '🏆'}
</div>
<h1
className={css({
fontSize: '3xl',
fontWeight: 'bold',
marginBottom: '8px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
backgroundClip: 'text',
color: 'transparent',
})}
>
{winnerMetadata?.name || 'Someone'} Wins!
</h1>
<p
className={css({
fontSize: 'xl',
color: 'gray.600',
})}
>
with {winnerScore} {winnerScore === 1 ? 'round' : 'rounds'} won
</p>
</div>
{/* Final Standings */}
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
textAlign: 'center',
})}
>
Final Standings
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '12px',
})}
>
{sortedPlayers.map((playerId, index) => {
const player = state.playerMetadata[playerId]
const score = state.scores[playerId] || 0
const isWinner = playerId === state.winner
return (
<div
key={playerId}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px',
background: isWinner ? 'linear-gradient(135deg, #fed7aa, #fdba74)' : 'gray.100',
borderRadius: '8px',
border: isWinner ? '2px solid' : 'none',
borderColor: isWinner ? 'orange.300' : undefined,
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '12px',
})}
>
<span
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: 'gray.400',
width: '32px',
textAlign: 'center',
})}
>
{index + 1}
</span>
<span className={css({ fontSize: '32px' })}>{player?.emoji || '🎮'}</span>
<span className={css({ fontSize: 'lg', fontWeight: '600' })}>
{player?.name || 'Unknown'}
</span>
</div>
<div
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: isWinner ? 'orange.700' : 'gray.700',
})}
>
{score} {isWinner && '🏆'}
</div>
</div>
)
})}
</div>
</div>
{/* Game Stats */}
<div
className={css({
background: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '12px',
padding: '16px',
marginBottom: '24px',
textAlign: 'center',
})}
>
<h3
className={css({
fontSize: 'md',
fontWeight: 'bold',
marginBottom: '8px',
})}
>
Game Stats
</h3>
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
{state.roundNumber} {state.roundNumber === 1 ? 'round' : 'rounds'} played
</p>
<p className={css({ color: 'gray.600', fontSize: 'sm' })}>
{state.guesses.length} {state.guesses.length === 1 ? 'guess' : 'guesses'} made
</p>
</div>
{/* Actions */}
<button
type="button"
onClick={goToSetup}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '12px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
},
})}
>
Play Again
</button>
</div>
)
}

View File

@@ -1,197 +0,0 @@
/**
* Setup Phase - Game configuration
*/
'use client'
import { css } from '../../../../styled-system/css'
import { useNumberGuesser } from '../Provider'
export function SetupPhase() {
const { state, startGame, setConfig } = useNumberGuesser()
return (
<div
className={css({
padding: '32px',
maxWidth: '600px',
margin: '0 auto',
})}
>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
marginBottom: '24px',
textAlign: 'center',
})}
>
🎯 Number Guesser Setup
</h2>
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
})}
>
Game Rules
</h3>
<ul
className={css({
listStyle: 'disc',
paddingLeft: '24px',
lineHeight: '1.6',
color: 'gray.700',
})}
>
<li>One player chooses a secret number</li>
<li>Other players take turns guessing</li>
<li>Get feedback on how close your guess is</li>
<li>First to guess correctly wins the round!</li>
<li>First to {state.roundsToWin} rounds wins the game!</li>
</ul>
</div>
<div
className={css({
background: 'white',
border: '2px solid',
borderColor: 'orange.200',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
})}
>
<h3
className={css({
fontSize: 'lg',
fontWeight: 'bold',
marginBottom: '16px',
})}
>
Configuration
</h3>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '16px',
})}
>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Minimum Number
</label>
<input
type="number"
value={state.minNumber ?? 1}
onChange={(e) => setConfig('minNumber', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Maximum Number
</label>
<input
type="number"
value={state.maxNumber ?? 100}
onChange={(e) => setConfig('maxNumber', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
<div>
<label
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: '600',
marginBottom: '4px',
})}
>
Rounds to Win
</label>
<input
type="number"
value={state.roundsToWin ?? 3}
onChange={(e) => setConfig('roundsToWin', Number.parseInt(e.target.value, 10))}
className={css({
width: '100%',
padding: '8px 12px',
border: '1px solid',
borderColor: 'gray.300',
borderRadius: '6px',
fontSize: 'md',
})}
/>
</div>
</div>
</div>
<button
onClick={startGame}
className={css({
width: '100%',
padding: '16px',
background: 'linear-gradient(135deg, #fb923c, #f97316)',
color: 'white',
border: 'none',
borderRadius: '12px',
fontSize: 'lg',
fontWeight: 'bold',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(249, 115, 22, 0.3)',
},
})}
>
Start Game
</button>
</div>
)
}

View File

@@ -1,15 +0,0 @@
name: number-guesser
displayName: Number Guesser
icon: 🎯
description: Classic turn-based number guessing game
longDescription: One player thinks of a number, others take turns guessing. Get hot/cold feedback as you try to find the secret number. Perfect for testing your deduction skills!
maxPlayers: 4
difficulty: Beginner
chips:
- 👥 Multiplayer
- 🎲 Turn-Based
- 🧠 Logic Puzzle
color: orange
gradient: linear-gradient(135deg, #fed7aa, #fdba74)
borderColor: orange.200
available: true

View File

@@ -1,66 +0,0 @@
/**
* Number Guesser Game Definition
* Exports the complete game using the Arcade SDK
*/
import { defineGame } from '@/lib/arcade/game-sdk'
import type { GameManifest } from '@/lib/arcade/game-sdk'
import { GameComponent } from './components/GameComponent'
import { NumberGuesserProvider } from './Provider'
import type { NumberGuesserConfig, NumberGuesserMove, NumberGuesserState } from './types'
import { numberGuesserValidator } from './Validator'
// Game manifest (matches game.yaml)
const manifest: GameManifest = {
name: 'number-guesser',
displayName: 'Number Guesser',
icon: '🎯',
description: 'Classic turn-based number guessing game',
longDescription:
'One player thinks of a number, others take turns guessing. Get hot/cold feedback to narrow down your guesses. First to guess wins the round!',
maxPlayers: 4,
difficulty: 'Beginner',
chips: ['👥 Multiplayer', '🎲 Turn-Based', '🧠 Logic Puzzle'],
color: 'orange',
gradient: 'linear-gradient(135deg, #fed7aa, #fdba74)',
borderColor: 'orange.200',
available: true,
}
// Default configuration
const defaultConfig: NumberGuesserConfig = {
minNumber: 1,
maxNumber: 100,
roundsToWin: 3,
}
// Config validation function
function validateNumberGuesserConfig(config: unknown): config is NumberGuesserConfig {
return (
typeof config === 'object' &&
config !== null &&
'minNumber' in config &&
'maxNumber' in config &&
'roundsToWin' in config &&
typeof config.minNumber === 'number' &&
typeof config.maxNumber === 'number' &&
typeof config.roundsToWin === 'number' &&
config.minNumber >= 1 &&
config.maxNumber > config.minNumber &&
config.roundsToWin >= 1
)
}
// Export game definition
export const numberGuesserGame = defineGame<
NumberGuesserConfig,
NumberGuesserState,
NumberGuesserMove
>({
manifest,
Provider: NumberGuesserProvider,
GameComponent,
validator: numberGuesserValidator,
defaultConfig,
validateConfig: validateNumberGuesserConfig,
})

View File

@@ -1,116 +0,0 @@
/**
* Type definitions for Number Guesser game
*/
import type { GameMove } from '@/lib/arcade/game-sdk'
/**
* Game configuration
*/
export type NumberGuesserConfig = {
minNumber: number
maxNumber: number
roundsToWin: number
}
/**
* A single guess attempt
*/
export interface Guess {
playerId: string
playerName: string
guess: number
distance: number // How far from the secret number
timestamp: number
}
/**
* Game phases
*/
export type GamePhase = 'setup' | 'choosing' | 'guessing' | 'results'
/**
* Game state
*/
export type NumberGuesserState = {
// Configuration
minNumber: number
maxNumber: number
roundsToWin: number
// Game phase
gamePhase: GamePhase
// Players
activePlayers: string[]
playerMetadata: Record<string, { name: string; emoji: string; color: string; userId: string }>
// Current round
secretNumber: number | null
chooser: string // Player ID who chose the number
currentGuesser: string // Player ID whose turn it is to guess
// Round history
guesses: Guess[]
roundNumber: number
// Scores
scores: Record<string, number>
// Game state
gameStartTime: number | null
gameEndTime: number | null
winner: string | null
}
/**
* Game moves
*/
export interface StartGameMove extends GameMove {
type: 'START_GAME'
data: {
activePlayers: string[]
playerMetadata: Record<string, unknown>
}
}
export interface ChooseNumberMove extends GameMove {
type: 'CHOOSE_NUMBER'
data: {
secretNumber: number
}
}
export interface MakeGuessMove extends GameMove {
type: 'MAKE_GUESS'
data: {
guess: number
playerName: string
}
}
export interface NextRoundMove extends GameMove {
type: 'NEXT_ROUND'
data: Record<string, never>
}
export interface GoToSetupMove extends GameMove {
type: 'GO_TO_SETUP'
data: Record<string, never>
}
export interface SetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'minNumber' | 'maxNumber' | 'roundsToWin'
value: number
}
}
export type NumberGuesserMove =
| StartGameMove
| ChooseNumberMove
| MakeGuessMove
| NextRoundMove
| GoToSetupMove
| SetConfigMove

View File

@@ -14,8 +14,7 @@ import {
DEFAULT_MATCHING_CONFIG,
DEFAULT_MEMORY_QUIZ_CONFIG,
DEFAULT_COMPLEMENT_RACE_CONFIG,
DEFAULT_NUMBER_GUESSER_CONFIG,
DEFAULT_MATH_SPRINT_CONFIG,
DEFAULT_CARD_SORTING_CONFIG,
} from './game-configs'
// Lazy-load game registry to avoid loading React components on server
@@ -51,10 +50,8 @@ function getDefaultGameConfig(gameName: ExtendedGameName): GameConfigByName[Exte
return DEFAULT_MEMORY_QUIZ_CONFIG
case 'complement-race':
return DEFAULT_COMPLEMENT_RACE_CONFIG
case 'number-guesser':
return DEFAULT_NUMBER_GUESSER_CONFIG
case 'math-sprint':
return DEFAULT_MATH_SPRINT_CONFIG
case 'card-sorting':
return DEFAULT_CARD_SORTING_CONFIG
default:
throw new Error(`Unknown game: ${gameName}`)
}

View File

@@ -2,7 +2,7 @@
* Shared game configuration types
*
* ARCHITECTURE: Phase 3 - Type Inference
* - Modern games (number-guesser, math-sprint, memory-quiz, matching): Types inferred from game definitions
* - Modern games (memory-quiz, matching): Types inferred from game definitions
* - Legacy games (complement-race): Manual types until migrated
*
* These types are used across:
@@ -13,10 +13,9 @@
*/
// Type-only imports (won't load React components at runtime)
import type { numberGuesserGame } from '@/arcade-games/number-guesser'
import type { mathSprintGame } from '@/arcade-games/math-sprint'
import type { memoryQuizGame } from '@/arcade-games/memory-quiz'
import type { matchingGame } from '@/arcade-games/matching'
import type { cardSortingGame } from '@/arcade-games/card-sorting'
/**
* Utility type: Extract config type from a game definition
@@ -28,18 +27,6 @@ type InferGameConfig<T> = T extends { defaultConfig: infer Config } ? Config : n
// Modern Games (Type Inference from Game Definitions)
// ============================================================================
/**
* Configuration for number-guesser game
* INFERRED from numberGuesserGame.defaultConfig
*/
export type NumberGuesserGameConfig = InferGameConfig<typeof numberGuesserGame>
/**
* Configuration for math-sprint game
* INFERRED from mathSprintGame.defaultConfig
*/
export type MathSprintGameConfig = InferGameConfig<typeof mathSprintGame>
/**
* Configuration for memory-quiz (soroban lightning) game
* INFERRED from memoryQuizGame.defaultConfig
@@ -52,6 +39,12 @@ export type MemoryQuizGameConfig = InferGameConfig<typeof memoryQuizGame>
*/
export type MatchingGameConfig = InferGameConfig<typeof matchingGame>
/**
* Configuration for card-sorting (pattern recognition) game
* INFERRED from cardSortingGame.defaultConfig
*/
export type CardSortingGameConfig = InferGameConfig<typeof cardSortingGame>
// ============================================================================
// Legacy Games (Manual Type Definitions)
// TODO: Migrate these games to the modular system for type inference
@@ -108,10 +101,9 @@ export interface ComplementRaceGameConfig {
*/
export type GameConfigByName = {
// Modern games (inferred types)
'number-guesser': NumberGuesserGameConfig
'math-sprint': MathSprintGameConfig
'memory-quiz': MemoryQuizGameConfig
matching: MatchingGameConfig
'card-sorting': CardSortingGameConfig
// Legacy games (manual types)
'complement-race': ComplementRaceGameConfig
@@ -143,6 +135,12 @@ export const DEFAULT_MEMORY_QUIZ_CONFIG: MemoryQuizGameConfig = {
playMode: 'cooperative',
}
export const DEFAULT_CARD_SORTING_CONFIG: CardSortingGameConfig = {
cardCount: 8,
showNumbers: true,
timeLimit: null,
}
export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
// Game style
style: 'practice',
@@ -176,15 +174,3 @@ export const DEFAULT_COMPLEMENT_RACE_CONFIG: ComplementRaceGameConfig = {
targetScore: 100,
timeLimit: 300,
}
export const DEFAULT_NUMBER_GUESSER_CONFIG: NumberGuesserGameConfig = {
minNumber: 1,
maxNumber: 100,
roundsToWin: 3,
}
export const DEFAULT_MATH_SPRINT_CONFIG: MathSprintGameConfig = {
difficulty: 'medium',
questionsPerRound: 10,
timePerQuestion: 30,
}

View File

@@ -106,14 +106,12 @@ export function clearRegistry(): void {
// Game Registrations
// ============================================================================
import { numberGuesserGame } from '@/arcade-games/number-guesser'
import { mathSprintGame } from '@/arcade-games/math-sprint'
import { memoryQuizGame } from '@/arcade-games/memory-quiz'
import { matchingGame } from '@/arcade-games/matching'
import { complementRaceGame } from '@/arcade-games/complement-race/index'
import { cardSortingGame } from '@/arcade-games/card-sorting'
registerGame(numberGuesserGame)
registerGame(mathSprintGame)
registerGame(memoryQuizGame)
registerGame(matchingGame)
registerGame(complementRaceGame)
registerGame(cardSortingGame)

View File

@@ -12,7 +12,7 @@ export {
validatorRegistry,
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
cardSortingValidator,
} from '../validators'
export type { GameName } from '../validators'

View File

@@ -40,8 +40,7 @@ export interface GameMove {
*/
export type { MatchingMove } from '@/arcade-games/matching/types'
export type { MemoryQuizMove } from '@/arcade-games/memory-quiz/types'
export type { NumberGuesserMove } from '@/arcade-games/number-guesser/types'
export type { MathSprintMove } from '@/arcade-games/math-sprint/types'
export type { CardSortingMove } from '@/arcade-games/card-sorting/types'
export type { ComplementRaceMove } from '@/arcade-games/complement-race/types'
/**
@@ -49,8 +48,7 @@ export type { ComplementRaceMove } from '@/arcade-games/complement-race/types'
*/
export type { MatchingState } from '@/arcade-games/matching/types'
export type { MemoryQuizState } from '@/arcade-games/memory-quiz/types'
export type { NumberGuesserState } from '@/arcade-games/number-guesser/types'
export type { MathSprintState } from '@/arcade-games/math-sprint/types'
export type { CardSortingState } from '@/arcade-games/card-sorting/types'
export type { ComplementRaceState } from '@/arcade-games/complement-race/types'
// Generic game state union (for backwards compatibility)

View File

@@ -12,9 +12,8 @@
import { matchingGameValidator } from '@/arcade-games/matching/Validator'
import { memoryQuizGameValidator } from '@/arcade-games/memory-quiz/Validator'
import { numberGuesserValidator } from '@/arcade-games/number-guesser/Validator'
import { mathSprintValidator } from '@/arcade-games/math-sprint/Validator'
import { complementRaceValidator } from '@/arcade-games/complement-race/Validator'
import { cardSortingValidator } from '@/arcade-games/card-sorting/Validator'
import type { GameValidator } from './validation/types'
/**
@@ -25,9 +24,8 @@ import type { GameValidator } from './validation/types'
export const validatorRegistry = {
matching: matchingGameValidator,
'memory-quiz': memoryQuizGameValidator,
'number-guesser': numberGuesserValidator,
'math-sprint': mathSprintValidator,
'complement-race': complementRaceValidator,
'card-sorting': cardSortingValidator,
// Add new games here - GameName type will auto-update
} as const
@@ -97,7 +95,6 @@ export function assertValidGameName(gameName: unknown): asserts gameName is Game
export {
matchingGameValidator,
memoryQuizGameValidator,
numberGuesserValidator,
mathSprintValidator,
complementRaceValidator,
cardSortingValidator,
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.7.0",
"version": "4.9.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [