feat(memory-quiz): add multiplayer support with redesigned scoreboards

- Add multiplayer state tracking (playerMetadata, playerScores, activePlayers)
- Add cooperative and competitive play modes
- Preserve multiplayer state through server-side validation
- Redesign scoreboard layout to stack players vertically with larger stats
- Add live scoreboard during gameplay (competitive mode)
- Add final leaderboard on results screen for both modes
- Track scores by userId to properly handle multi-player teams

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-15 09:15:35 -05:00
parent 297927401c
commit 1cf44696c2
11 changed files with 1113 additions and 126 deletions

View File

@@ -1,24 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { isPrefix } from '@/lib/memory-quiz-utils'
import { useMemoryQuiz } from '../context/MemoryQuizContext'
import { CardGrid } from './CardGrid'
export function InputPhase() {
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
const _containerRef = useRef<HTMLDivElement>(null)
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
'neutral'
)
// Use a ref to track the current input to avoid stale reads during rapid typing
// This prevents the "type 8 times" issue where state.currentInput hasn't updated yet
const currentInputRef = useRef(state.currentInput)
// Keep ref in sync with state
useEffect(() => {
currentInputRef.current = state.currentInput
}, [state.currentInput])
// Use keyboard state from parent state instead of local state
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
@@ -116,8 +106,7 @@ export function InputPhase() {
const acceptCorrectNumber = useCallback(
(number: number) => {
acceptNumber?.(number)
currentInputRef.current = '' // Clear ref immediately
setInput?.('')
// setInput('') is called inside acceptNumber action creator
setDisplayFeedback('correct')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -127,11 +116,11 @@ export function InputPhase() {
setTimeout(() => showResults?.(), 1000)
}
},
[acceptNumber, setInput, showResults, state.foundNumbers.length, state.correctAnswers.length]
[acceptNumber, showResults, state.foundNumbers.length, state.correctAnswers.length]
)
const handleIncorrectGuess = useCallback(() => {
const wrongNumber = parseInt(currentInputRef.current, 10)
const wrongNumber = parseInt(state.currentInput, 10)
if (!Number.isNaN(wrongNumber)) {
dispatch({ type: 'ADD_WRONG_GUESS_ANIMATION', number: wrongNumber })
// Clear wrong guess animations after explosion
@@ -141,8 +130,7 @@ export function InputPhase() {
}
rejectNumber?.()
currentInputRef.current = '' // Clear ref immediately
setInput?.('')
// setInput('') is called inside rejectNumber action creator
setDisplayFeedback('incorrect')
setTimeout(() => setDisplayFeedback('neutral'), 500)
@@ -151,7 +139,7 @@ export function InputPhase() {
if (state.guessesRemaining - 1 === 0) {
setTimeout(() => showResults?.(), 1000)
}
}, [dispatch, rejectNumber, setInput, showResults, state.guessesRemaining])
}, [state.currentInput, dispatch, rejectNumber, showResults, state.guessesRemaining])
// Simple keyboard event handlers that will be defined after callbacks
const handleKeyboardInput = useCallback(
@@ -161,9 +149,8 @@ export function InputPhase() {
// Only handle if input phase is active and guesses remain
if (state.guessesRemaining === 0) return
// Use ref for immediate value, update ref right away to prevent stale reads
const newInput = currentInputRef.current + key
currentInputRef.current = newInput // Update ref immediately for next keypress
// Update input with new key
const newInput = state.currentInput + key
setInput?.(newInput)
// Clear any existing timeout
@@ -215,9 +202,8 @@ export function InputPhase() {
)
const handleKeyboardBackspace = useCallback(() => {
if (currentInputRef.current.length > 0) {
const newInput = currentInputRef.current.slice(0, -1)
currentInputRef.current = newInput // Update ref immediately
if (state.currentInput.length > 0) {
const newInput = state.currentInput.slice(0, -1)
setInput?.(newInput)
// Clear any existing timeout
@@ -228,7 +214,7 @@ export function InputPhase() {
setDisplayFeedback('neutral')
}
}, [state.prefixAcceptanceTimeout, dispatch, setInput])
}, [state.currentInput, state.prefixAcceptanceTimeout, dispatch, setInput])
// Set up global keyboard listeners
useEffect(() => {
@@ -383,6 +369,172 @@ export function InputPhase() {
</div>
</div>
{/* Live Scoreboard - Competitive Mode Only */}
{state.playMode === 'competitive' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '12px',
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
borderRadius: '8px',
border: '2px solid #f59e0b',
}}
>
<div
style={{
fontSize: '12px',
fontWeight: 'bold',
color: '#92400e',
marginBottom: '8px',
textAlign: 'center',
}}
>
🏆 LIVE SCOREBOARD
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('📊 [InputPhase] Building scoreboard:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('📊 [InputPhase] Processing player for scoreboard:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('📊 [InputPhase] UserTeams created:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by score
return Array.from(userTeams.values())
.sort((a, b) => {
const aScore = a.score.correct - a.score.incorrect * 0.5
const bScore = b.score.correct - b.score.incorrect * 0.5
return bScore - aScore
})
.map((team, index) => {
const netScore = team.score.correct - team.score.incorrect * 0.5
return (
<div
key={team.userId}
style={{
padding: '10px 12px',
background: index === 0 ? '#fef3c7' : 'white',
borderRadius: '8px',
border: index === 0 ? '2px solid #f59e0b' : '1px solid #e5e7eb',
}}
>
{/* Team header with rank and stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '18px' }}>
{index === 0 ? '👑' : `${index + 1}.`}
</span>
<span
style={{
fontWeight: 'bold',
fontSize: '16px',
color: '#1f2937',
}}
>
Score: {netScore.toFixed(1)}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
}}
>
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
{team.score.correct}
</span>
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
{team.score.incorrect}
</span>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '26px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<span style={{ fontSize: '16px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</div>
)
})
})()}
</div>
</div>
)}
<div
style={{
position: 'relative',

View File

@@ -80,78 +80,78 @@ export function MemoryQuizGame() {
>
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)',
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/arcade"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Champion Arena
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
}}
maxHeight: '100%',
})}
>
<div
className={css({
textAlign: 'center',
mb: '4',
flexShrink: 0,
})}
>
<Link
href="/arcade"
className={css({
display: 'inline-flex',
alignItems: 'center',
color: 'gray.600',
textDecoration: 'none',
mb: '4',
_hover: { color: 'gray.800' },
})}
>
Back to Champion Arena
</Link>
</div>
<div
className={css({
bg: 'white',
rounded: 'xl',
shadow: 'xl',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.200',
flex: 1,
display: 'flex',
flexDirection: 'column',
maxHeight: '100%',
overflow: 'auto',
})}
>
<div
className={css({
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
})}
>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
{state.gamePhase === 'setup' && <SetupPhase />}
{state.gamePhase === 'display' && <DisplayPhase />}
{state.gamePhase === 'input' && <InputPhase key="input-phase" />}
{state.gamePhase === 'results' && <ResultsPhase />}
</div>
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -131,6 +131,374 @@ export function ResultsPhase() {
</div>
</div>
{/* Multiplayer Leaderboard - Competitive Mode */}
{state.playMode === 'competitive' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)',
borderRadius: '12px',
border: '2px solid #f59e0b',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: 'bold',
color: '#92400e',
marginBottom: '12px',
textAlign: 'center',
}}
>
🏆 FINAL LEADERBOARD
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: number } }
>()
console.log('🏆 [ResultsPhase] Building leaderboard:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('🏆 [ResultsPhase] Processing player for leaderboard:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('🏆 [ResultsPhase] UserTeams created:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by score
return Array.from(userTeams.values())
.sort((a, b) => {
const aScore = a.score.correct - a.score.incorrect * 0.5
const bScore = b.score.correct - b.score.incorrect * 0.5
return bScore - aScore
})
.map((team, index) => {
const netScore = team.score.correct - team.score.incorrect * 0.5
return (
<div
key={team.userId}
style={{
padding: '14px 16px',
background:
index === 0
? 'linear-gradient(135deg, #fef3c7 0%, #fde68a 50%)'
: 'white',
borderRadius: '10px',
border: index === 0 ? '3px solid #f59e0b' : '1px solid #e5e7eb',
boxShadow: index === 0 ? '0 4px 12px rgba(245, 158, 11, 0.3)' : 'none',
}}
>
{/* Team header with rank and stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '10px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '24px', minWidth: '32px' }}>
{index === 0
? '🏆'
: index === 1
? '🥈'
: index === 2
? '🥉'
: `${index + 1}.`}
</span>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span
style={{
fontWeight: 'bold',
fontSize: index === 0 ? '20px' : '18px',
color: index === 0 ? '#f59e0b' : '#1f2937',
}}
>
{netScore.toFixed(1)}
</span>
{index === 0 && (
<span
style={{
fontSize: '11px',
color: '#92400e',
fontWeight: 'bold',
}}
>
CHAMPION
</span>
)}
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<span
style={{
color: '#10b981',
fontWeight: 'bold',
fontSize: '16px',
}}
>
{team.score.correct}
</span>
<span style={{ fontSize: '10px', color: '#6b7280' }}>correct</span>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<span
style={{
color: '#ef4444',
fontWeight: 'bold',
fontSize: '16px',
}}
>
{team.score.incorrect}
</span>
<span style={{ fontSize: '10px', color: '#6b7280' }}>wrong</span>
</div>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
paddingLeft: '42px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ fontSize: '18px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
fontSize: '14px',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</div>
)
})
})()}
</div>
</div>
)}
{/* Multiplayer Stats - Cooperative Mode */}
{state.playMode === 'cooperative' &&
state.activePlayers &&
state.activePlayers.length > 1 && (
<div
style={{
marginBottom: '16px',
padding: '16px',
background: 'linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%)',
borderRadius: '12px',
border: '2px solid #3b82f6',
}}
>
<div
style={{
fontSize: '16px',
fontWeight: 'bold',
color: '#1e3a8a',
marginBottom: '12px',
textAlign: 'center',
}}
>
🤝 TEAM CONTRIBUTIONS
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
{(() => {
// Group players by userId
const userTeams = new Map<
string,
{ userId: string; players: any[]; score: { correct: number; incorrect: 0 } }
>()
console.log('🤝 [ResultsPhase] Building team contributions:', {
activePlayers: state.activePlayers,
playerMetadata: state.playerMetadata,
playerScores: state.playerScores,
})
for (const playerId of state.activePlayers) {
const metadata = state.playerMetadata?.[playerId]
const userId = metadata?.userId
console.log('🤝 [ResultsPhase] Processing player for contributions:', {
playerId,
metadata,
userId,
})
if (!userId) continue
if (!userTeams.has(userId)) {
userTeams.set(userId, {
userId,
players: [],
score: state.playerScores?.[userId] || { correct: 0, incorrect: 0 },
})
}
userTeams.get(userId)!.players.push(metadata)
}
console.log('🤝 [ResultsPhase] UserTeams created for contributions:', {
count: userTeams.size,
teams: Array.from(userTeams.entries()),
})
// Sort teams by correct answers
return Array.from(userTeams.values())
.sort((a, b) => b.score.correct - a.score.correct)
.map((team, index) => (
<div
key={team.userId}
style={{
padding: '12px 14px',
background: 'white',
borderRadius: '8px',
border: '1px solid #e5e7eb',
}}
>
{/* Team header with stats */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px', fontWeight: '600', color: '#6b7280' }}>
Team {index + 1}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
fontSize: '14px',
}}
>
<span style={{ color: '#10b981', fontWeight: 'bold' }}>
{team.score.correct}
</span>
<span style={{ color: '#ef4444', fontWeight: 'bold' }}>
{team.score.incorrect}
</span>
</div>
</div>
{/* Players list */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
paddingLeft: '8px',
}}
>
{team.players.map((player, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '13px',
}}
>
<span style={{ fontSize: '18px' }}>{player?.emoji || '🎮'}</span>
<span
style={{
color: '#1f2937',
fontWeight: '500',
}}
>
{player?.name || `Player ${i + 1}`}
</span>
</div>
))}
</div>
</div>
))
})()}
</div>
</div>
)}
{/* Results card grid - reuse CardGrid but with all cards revealed and status indicators */}
<div style={{ marginTop: '12px', flex: 1, overflow: 'auto' }}>
<ResultsCardGrid state={state} />

View File

@@ -69,6 +69,10 @@ export function SetupPhase() {
setConfig?.('selectedDifficulty', difficulty)
}
const handlePlayModeSelect = (playMode: 'cooperative' | 'competitive') => {
setConfig?.('playMode', playMode)
}
const handleStartQuiz = () => {
const quizCards = generateQuizCards(
state.selectedCount ?? 5,
@@ -150,6 +154,75 @@ export function SetupPhase() {
</div>
</div>
<div style={{ margin: '12px 0' }}>
<label
style={{
display: 'block',
fontWeight: 'bold',
marginBottom: '8px',
color: '#6b7280',
fontSize: '14px',
}}
>
Play Mode:
</label>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
justifyContent: 'center',
}}
>
<button
key="cooperative"
type="button"
style={{
background: state.playMode === 'cooperative' ? '#10b981' : 'white',
color: state.playMode === 'cooperative' ? 'white' : '#1f2937',
border: '2px solid',
borderColor: state.playMode === 'cooperative' ? '#10b981' : '#d1d5db',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '2px',
fontSize: '12px',
}}
onClick={() => handlePlayModeSelect('cooperative')}
title="Work together as a team to find all numbers"
>
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>🤝 Cooperative</div>
<div style={{ fontSize: '10px', opacity: 0.8 }}>Work together</div>
</button>
<button
key="competitive"
type="button"
style={{
background: state.playMode === 'competitive' ? '#ef4444' : 'white',
color: state.playMode === 'competitive' ? 'white' : '#1f2937',
border: '2px solid',
borderColor: state.playMode === 'competitive' ? '#ef4444' : '#d1d5db',
borderRadius: '8px',
padding: '8px 12px',
cursor: 'pointer',
textAlign: 'center',
display: 'flex',
flexDirection: 'column',
gap: '2px',
fontSize: '12px',
}}
onClick={() => handlePlayModeSelect('competitive')}
title="Compete for the highest score"
>
<div style={{ fontWeight: 'bold', fontSize: '13px' }}>🏆 Competitive</div>
<div style={{ fontSize: '10px', opacity: 0.8 }}>Battle for score</div>
</button>
</div>
</div>
<div style={{ margin: '12px 0' }}>
<label
style={{

View File

@@ -25,7 +25,10 @@ export interface MemoryQuizContextValue {
rejectNumber?: () => void
setInput?: (input: string) => void
showResults?: () => void
setConfig?: (field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => void
setConfig?: (
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
) => void
}
// Create context

View File

@@ -1,11 +1,17 @@
'use client'
import type { ReactNode } from 'react'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useArcadeSession } from '@/hooks/useArcadeSession'
import { useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import type { GameMove } from '@/lib/arcade/validation'
import { TEAM_MOVE } from '@/lib/arcade/validation/types'
import {
buildPlayerMetadata as buildPlayerMetadataUtil,
buildPlayerOwnershipFromRoomData,
} from '@/lib/arcade/player-ownership.client'
import { initialState } from '../reducer'
import type { QuizCard, SorobanQuizState } from '../types'
import { MemoryQuizContext, type MemoryQuizContextValue } from './MemoryQuizContext'
@@ -45,6 +51,39 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
const cardCount = quizCards.length
// Initialize player scores for all active players (by userId, not playerId)
const activePlayers = move.data.activePlayers || []
const playerMetadata = move.data.playerMetadata || {}
console.log('🎯 [START_QUIZ] Initializing player scores:', {
activePlayers,
playerMetadata,
})
// Extract unique userIds from playerMetadata
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
console.log('🎯 [START_QUIZ] Processing player:', {
playerId,
metadata,
hasUserId: !!metadata?.userId,
})
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
console.log('🎯 [START_QUIZ] Unique userIds found:', Array.from(uniqueUserIds))
// Initialize scores for each userId
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
console.log('🎯 [START_QUIZ] Initialized playerScores:', playerScores)
return {
...state,
quizCards,
@@ -57,6 +96,10 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
}
}
@@ -72,26 +115,80 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
gamePhase: 'input',
}
case 'ACCEPT_NUMBER':
case 'ACCEPT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
const foundNumbers = state.foundNumbers || []
const numberFoundBy = state.numberFoundBy || {}
console.log('✅ [ACCEPT_NUMBER] Before update:', {
moveUserId: move.userId,
currentPlayerScores: playerScores,
number: move.data.number,
})
const newPlayerScores = { ...playerScores }
const newNumberFoundBy = { ...numberFoundBy }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[move.data.number] = move.userId
console.log('✅ [ACCEPT_NUMBER] After update:', {
userId: move.userId,
newScore: newPlayerScores[move.userId],
allScores: newPlayerScores,
numberFoundBy: move.data.number,
})
} else {
console.warn('⚠️ [ACCEPT_NUMBER] No userId in move!')
}
return {
...state,
foundNumbers: [...state.foundNumbers, move.data.number],
currentInput: '',
foundNumbers: [...foundNumbers, move.data.number],
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
}
case 'REJECT_NUMBER':
case 'REJECT_NUMBER': {
// Track scores by userId (not playerId) since we can't determine which player typed
// Defensive check: ensure state properties exist
const playerScores = state.playerScores || {}
console.log('❌ [REJECT_NUMBER] Before update:', {
moveUserId: move.userId,
currentPlayerScores: playerScores,
})
const newPlayerScores = { ...playerScores }
if (move.userId) {
const currentScore = newPlayerScores[move.userId] || { correct: 0, incorrect: 0 }
newPlayerScores[move.userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
console.log('❌ [REJECT_NUMBER] After update:', {
userId: move.userId,
newScore: newPlayerScores[move.userId],
allScores: newPlayerScores,
})
} else {
console.warn('⚠️ [REJECT_NUMBER] No userId in move!')
}
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
}
case 'SET_INPUT':
return {
...state,
currentInput: move.data.input,
playerScores: newPlayerScores,
}
}
case 'SHOW_RESULTS':
return {
@@ -117,7 +214,7 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
case 'SET_CONFIG': {
const { field, value } = move.data as {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
return {
@@ -140,6 +237,14 @@ function applyMoveOptimistically(state: SorobanQuizState, move: GameMove): Sorob
export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const { data: viewerId } = useViewerId()
const { roomData } = useRoomData()
const { activePlayers: activePlayerIds, players } = useGameMode()
// Get active player IDs as array
const activePlayers = Array.from(activePlayerIds)
// LOCAL-ONLY state for current input (not synced over network)
// This prevents sending a network request for every keystroke
const [localCurrentInput, setLocalCurrentInput] = useState('')
// Arcade session integration WITH room sync
const {
@@ -154,6 +259,13 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
applyMove: applyMoveOptimistically,
})
// Clear local input when game phase changes or when game resets
useEffect(() => {
if (state.gamePhase !== 'input') {
setLocalCurrentInput('')
}
}, [state.gamePhase])
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
@@ -163,33 +275,71 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
}
}, [state.prefixAcceptanceTimeout])
// Detect state corruption/mismatch (e.g., game type mismatch between sessions)
const hasStateCorruption =
!state.quizCards ||
!state.correctAnswers ||
!state.foundNumbers ||
!Array.isArray(state.quizCards)
// Computed values
const isGameActive = state.gamePhase === 'display' || state.gamePhase === 'input'
// Build player metadata from room data and player map
const buildPlayerMetadata = useCallback(() => {
console.log('🔍 [buildPlayerMetadata] Starting:', {
roomData: roomData?.id,
activePlayers,
viewerId,
playersMapSize: players.size,
})
const playerOwnership = buildPlayerOwnershipFromRoomData(roomData)
console.log('🔍 [buildPlayerMetadata] Player ownership:', playerOwnership)
const metadata = buildPlayerMetadataUtil(activePlayers, playerOwnership, players, viewerId)
console.log('🔍 [buildPlayerMetadata] Built metadata:', metadata)
return metadata
}, [activePlayers, players, roomData, viewerId])
// Action creators - send moves to arcade session
// For single-player quiz, we use viewerId as playerId
const startQuiz = useCallback(
(quizCards: QuizCard[]) => {
// Extract only serializable data (numbers) for server
// React components can't be sent over Socket.IO
const numbers = quizCards.map((card) => card.number)
// Build player metadata for multiplayer
const playerMetadata = buildPlayerMetadata()
console.log('🚀 [startQuiz] Sending START_QUIZ move:', {
viewerId,
activePlayers,
playerMetadata,
numbers,
})
sendMove({
type: 'START_QUIZ',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - all players act together
userId: viewerId || '', // User who initiated
data: {
numbers, // Send to server
quizCards, // Keep for optimistic local update
activePlayers, // Send active players list
playerMetadata, // Send player display info
},
})
},
[viewerId, sendMove]
[viewerId, sendMove, activePlayers, buildPlayerMetadata]
)
const nextCard = useCallback(() => {
sendMove({
type: 'NEXT_CARD',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
@@ -197,16 +347,26 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const showInputPhase = useCallback(() => {
sendMove({
type: 'SHOW_INPUT_PHASE',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const acceptNumber = useCallback(
(number: number) => {
// Clear local input immediately
setLocalCurrentInput('')
console.log('🚀 [acceptNumber] Sending ACCEPT_NUMBER move:', {
viewerId,
number,
})
sendMove({
type: 'ACCEPT_NUMBER',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed correctly
data: { number },
})
},
@@ -214,28 +374,32 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
const rejectNumber = useCallback(() => {
// Clear local input immediately
setLocalCurrentInput('')
console.log('🚀 [rejectNumber] Sending REJECT_NUMBER move:', {
viewerId,
})
sendMove({
type: 'REJECT_NUMBER',
playerId: viewerId || '',
playerId: TEAM_MOVE, // Team move - can't identify specific player
userId: viewerId || '', // User who guessed incorrectly
data: {},
})
}, [viewerId, sendMove])
const setInput = useCallback(
(input: string) => {
sendMove({
type: 'SET_INPUT',
playerId: viewerId || '',
data: { input },
})
},
[viewerId, sendMove]
)
const setInput = useCallback((input: string) => {
// LOCAL ONLY - no network sync!
// This makes typing instant with zero network lag
setLocalCurrentInput(input)
}, [])
const showResults = useCallback(() => {
sendMove({
type: 'SHOW_RESULTS',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
@@ -243,24 +407,126 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
const resetGame = useCallback(() => {
sendMove({
type: 'RESET_QUIZ',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: {},
})
}, [viewerId, sendMove])
const setConfig = useCallback(
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty', value: any) => {
(field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode', value: any) => {
sendMove({
type: 'SET_CONFIG',
playerId: viewerId || '',
playerId: TEAM_MOVE,
userId: viewerId || '',
data: { field, value },
})
},
[viewerId, sendMove]
)
// Merge network state with local input state
const mergedState = {
...state,
currentInput: localCurrentInput, // Override network state with local input
}
// If state is corrupted, show error message instead of crashing
if (hasStateCorruption) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px',
textAlign: 'center',
minHeight: '400px',
}}
>
<div
style={{
fontSize: '48px',
marginBottom: '20px',
}}
>
</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: '#dc2626',
}}
>
Game State Mismatch
</h2>
<p
style={{
fontSize: '16px',
color: '#6b7280',
marginBottom: '24px',
maxWidth: '500px',
}}
>
There's a mismatch between game types in this room. This usually happens when room members
are playing different games.
</p>
<div
style={{
background: '#f9fafb',
border: '1px solid #e5e7eb',
borderRadius: '8px',
padding: '16px',
marginBottom: '24px',
maxWidth: '500px',
}}
>
<p
style={{
fontSize: '14px',
fontWeight: '600',
marginBottom: '8px',
}}
>
To fix this:
</p>
<ol
style={{
fontSize: '14px',
textAlign: 'left',
paddingLeft: '20px',
lineHeight: '1.6',
}}
>
<li>Make sure all room members are on the same game page</li>
<li>Try refreshing the page</li>
<li>If the issue persists, leave and rejoin the room</li>
</ol>
</div>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
background: '#3b82f6',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
}}
>
Refresh Page
</button>
</div>
)
}
const contextValue: MemoryQuizContextValue = {
state,
state: mergedState,
dispatch: () => {
// No-op - replaced with action creators
console.warn('dispatch() is deprecated in room mode, use action creators instead')

View File

@@ -12,6 +12,13 @@ export const initialState: SorobanQuizState = {
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative', // Default to cooperative
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,
@@ -32,6 +39,8 @@ export function quizReducer(state: SorobanQuizState, action: QuizAction): Soroba
return { ...state, selectedCount: action.count }
case 'SET_DIFFICULTY':
return { ...state, selectedDifficulty: action.difficulty }
case 'SET_PLAY_MODE':
return { ...state, playMode: action.playMode }
case 'START_QUIZ':
return {
...state,
@@ -46,19 +55,41 @@ export function quizReducer(state: SorobanQuizState, action: QuizAction): Soroba
return { ...state, currentCardIndex: state.currentCardIndex + 1 }
case 'SHOW_INPUT_PHASE':
return { ...state, gamePhase: 'input' }
case 'ACCEPT_NUMBER':
case 'ACCEPT_NUMBER': {
// In competitive mode, track which player guessed correctly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
correct: currentScore.correct + 1,
}
}
return {
...state,
foundNumbers: [...state.foundNumbers, action.number],
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'REJECT_NUMBER': {
// In competitive mode, track which player guessed incorrectly
const newPlayerScores = { ...state.playerScores }
if (state.playMode === 'competitive' && action.playerId) {
const currentScore = newPlayerScores[action.playerId] || { correct: 0, incorrect: 0 }
newPlayerScores[action.playerId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
case 'REJECT_NUMBER':
return {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
}
case 'SET_INPUT':
return { ...state, currentInput: action.input }
case 'SET_PREFIX_TIMEOUT':
@@ -89,6 +120,7 @@ export function quizReducer(state: SorobanQuizState, action: QuizAction): Soroba
displayTime: state.displayTime,
selectedCount: state.selectedCount,
selectedDifficulty: state.selectedDifficulty,
playMode: state.playMode, // Preserve play mode
// Preserve keyboard state across resets
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
testingMode: state.testingMode,

View File

@@ -1,9 +1,16 @@
import type { PlayerMetadata } from '@/lib/arcade/player-ownership.client'
export interface QuizCard {
number: number
svgComponent: JSX.Element
svgComponent: JSX.Element | null
element: HTMLElement | null
}
export interface PlayerScore {
correct: number
incorrect: number
}
export interface SorobanQuizState {
// Core game data
cards: QuizCard[]
@@ -22,6 +29,13 @@ export interface SorobanQuizState {
currentInput: string
incorrectGuesses: number
// Multiplayer state
activePlayers: string[]
playerMetadata: Record<string, PlayerMetadata>
playerScores: Record<string, PlayerScore>
playMode: 'cooperative' | 'competitive'
numberFoundBy: Record<number, string> // Maps number to userId who found it
// UI state
gamePhase: 'setup' | 'display' | 'input' | 'results'
prefixAcceptanceTimeout: NodeJS.Timeout | null
@@ -43,11 +57,12 @@ export type QuizAction =
| { type: 'SET_DISPLAY_TIME'; time: number }
| { type: 'SET_SELECTED_COUNT'; count: number }
| { type: 'SET_DIFFICULTY'; difficulty: DifficultyLevel }
| { type: 'SET_PLAY_MODE'; playMode: 'cooperative' | 'competitive' }
| { type: 'START_QUIZ'; quizCards: QuizCard[] }
| { type: 'NEXT_CARD' }
| { type: 'SHOW_INPUT_PHASE' }
| { type: 'ACCEPT_NUMBER'; number: number }
| { type: 'REJECT_NUMBER' }
| { type: 'ACCEPT_NUMBER'; number: number; playerId?: string }
| { type: 'REJECT_NUMBER'; playerId?: string }
| { type: 'ADD_WRONG_GUESS_ANIMATION'; number: number }
| { type: 'CLEAR_WRONG_GUESS_ANIMATIONS' }
| { type: 'SET_INPUT'; input: string }

View File

@@ -9,13 +9,13 @@ export const GAMES_CONFIG = {
'memory-quiz': {
name: 'Memory Lightning',
fullName: 'Memory Lightning ⚡',
maxPlayers: 1,
maxPlayers: 4,
description: 'Test your memory speed with rapid-fire abacus calculations',
longDescription:
'Challenge yourself with lightning-fast memory tests. Perfect your mental math skills with this intense solo experience.',
'Challenge yourself or compete with friends in lightning-fast memory tests. Work together cooperatively or compete for the highest score!',
url: '/arcade/memory-quiz',
icon: '⚡',
chips: ['⭐ Beginner Friendly', '🔥 Speed Challenge', '🧮 Abacus Focus'],
chips: ['👥 Multiplayer', '🔥 Speed Challenge', '🧮 Abacus Focus'],
color: 'green',
gradient: 'linear-gradient(135deg, #dcfce7, #bbf7d0)',
borderColor: 'green.200',

View File

@@ -30,10 +30,10 @@ export class MemoryQuizGameValidator
return this.validateShowInputPhase(state)
case 'ACCEPT_NUMBER':
return this.validateAcceptNumber(state, move.data.number)
return this.validateAcceptNumber(state, move.data.number, move.userId)
case 'REJECT_NUMBER':
return this.validateRejectNumber(state)
return this.validateRejectNumber(state, move.userId)
case 'SET_INPUT':
return this.validateSetInput(state, move.data.input)
@@ -83,6 +83,24 @@ export class MemoryQuizGameValidator
element: null,
}))
// Extract multiplayer data from move
const activePlayers = data.activePlayers || state.activePlayers || []
const playerMetadata = data.playerMetadata || state.playerMetadata || {}
// Initialize player scores for all active players (by userId)
const uniqueUserIds = new Set<string>()
for (const playerId of activePlayers) {
const metadata = playerMetadata[playerId]
if (metadata?.userId) {
uniqueUserIds.add(metadata.userId)
}
}
const playerScores = Array.from(uniqueUserIds).reduce((acc: any, userId: string) => {
acc[userId] = { correct: 0, incorrect: 0 }
return acc
}, {})
const newState: SorobanQuizState = {
...state,
quizCards,
@@ -95,6 +113,11 @@ export class MemoryQuizGameValidator
currentInput: '',
wrongGuessAnimations: [],
prefixAcceptanceTimeout: null,
// Multiplayer state
activePlayers,
playerMetadata,
playerScores,
numberFoundBy: {},
}
return {
@@ -143,7 +166,11 @@ export class MemoryQuizGameValidator
}
}
private validateAcceptNumber(state: SorobanQuizState, number: number): ValidationResult {
private validateAcceptNumber(
state: SorobanQuizState,
number: number,
userId?: string
): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -174,10 +201,28 @@ export class MemoryQuizGameValidator
}
}
// Update player scores (track by userId)
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
const numberFoundBy = state.numberFoundBy || {}
const newNumberFoundBy = { ...numberFoundBy }
if (userId) {
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
newPlayerScores[userId] = {
...currentScore,
correct: currentScore.correct + 1,
}
// Track who found this number
newNumberFoundBy[number] = userId
}
const newState: SorobanQuizState = {
...state,
foundNumbers: [...state.foundNumbers, number],
currentInput: '',
playerScores: newPlayerScores,
numberFoundBy: newNumberFoundBy,
}
return {
@@ -186,7 +231,7 @@ export class MemoryQuizGameValidator
}
}
private validateRejectNumber(state: SorobanQuizState): ValidationResult {
private validateRejectNumber(state: SorobanQuizState, userId?: string): ValidationResult {
// Must be in input phase
if (state.gamePhase !== 'input') {
return {
@@ -203,11 +248,23 @@ export class MemoryQuizGameValidator
}
}
// Update player scores (track by userId)
const playerScores = state.playerScores || {}
const newPlayerScores = { ...playerScores }
if (userId) {
const currentScore = newPlayerScores[userId] || { correct: 0, incorrect: 0 }
newPlayerScores[userId] = {
...currentScore,
incorrect: currentScore.incorrect + 1,
}
}
const newState: SorobanQuizState = {
...state,
guessesRemaining: state.guessesRemaining - 1,
incorrectGuesses: state.incorrectGuesses + 1,
currentInput: '',
playerScores: newPlayerScores,
}
return {
@@ -289,7 +346,7 @@ export class MemoryQuizGameValidator
private validateSetConfig(
state: SorobanQuizState,
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty',
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode',
value: any
): ValidationResult {
// Can only change config during setup phase
@@ -320,6 +377,12 @@ export class MemoryQuizGameValidator
}
break
case 'playMode':
if (!['cooperative', 'competitive'].includes(value)) {
return { valid: false, error: `Invalid playMode: ${value}` }
}
break
default:
return { valid: false, error: `Unknown config field: ${field}` }
}
@@ -355,6 +418,13 @@ export class MemoryQuizGameValidator
guessesRemaining: 0,
currentInput: '',
incorrectGuesses: 0,
// Multiplayer state
activePlayers: [],
playerMetadata: {},
playerScores: {},
playMode: 'cooperative',
numberFoundBy: {},
// UI state
gamePhase: 'setup',
prefixAcceptanceTimeout: null,
finishButtonsBound: false,

View File

@@ -14,9 +14,17 @@ export interface ValidationResult {
newState?: unknown
}
/**
* Sentinel value for team moves where no specific player can be identified
* Used in free-for-all games where all of a user's players act as a team
*/
export const TEAM_MOVE = '__TEAM__' as const
export type TeamMoveSentinel = typeof TEAM_MOVE
export interface GameMove {
type: string
playerId: string
playerId: string | TeamMoveSentinel // Individual player (turn-based) or __TEAM__ (free-for-all)
userId: string // Room member/viewer who made the move
timestamp: number
data: unknown
}
@@ -128,7 +136,7 @@ export interface MemoryQuizResetQuizMove extends GameMove {
export interface MemoryQuizSetConfigMove extends GameMove {
type: 'SET_CONFIG'
data: {
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty'
field: 'selectedCount' | 'displayTime' | 'selectedDifficulty' | 'playMode'
value: any
}
}