fix(memory-quiz): synchronize card display across all players in multiplayer

Fix race condition where each player's browser independently timed card
progression, causing desync where different players saw different numbers
of cards during the memorization phase.

Solution: Only the room creator controls card timing by sending NEXT_CARD
moves. All other players react to state.currentCardIndex changes from the
server, ensuring all players see the same cards at the same time.

- Add isRoomCreator flag to MemoryQuizContext
- Detect room creator in RoomMemoryQuizProvider
- Modify DisplayPhase to only call nextCard() if room creator or local mode
- Add debug logging to track timing control

🤖 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:25:29 -05:00
parent 86b75cba5a
commit 472f201088
3 changed files with 36 additions and 10 deletions

View File

@@ -12,13 +12,17 @@ function calculateMaxColumns(numbers: number[]): number {
}
export function DisplayPhase() {
const { state, nextCard, showInputPhase, resetGame } = useMemoryQuiz()
const { state, nextCard, showInputPhase, resetGame, isRoomCreator } = useMemoryQuiz()
const [currentCard, setCurrentCard] = useState<QuizCard | null>(null)
const [isTransitioning, setIsTransitioning] = useState(false)
const isDisplayPhaseActive = state.currentCardIndex < state.quizCards.length
const isProcessingRef = useRef(false)
const appConfig = useAbacusConfig()
// In multiplayer room mode, only the room creator controls card timing
// In local mode (isRoomCreator === undefined), allow timing control
const shouldControlTiming = isRoomCreator === undefined || isRoomCreator === true
// Calculate maximum columns needed for this quiz set
const maxColumns = useMemo(() => {
const allNumbers = state.quizCards.map((card) => card.number)
@@ -35,7 +39,10 @@ export function DisplayPhase() {
useEffect(() => {
if (state.currentCardIndex >= state.quizCards.length) {
showInputPhase?.()
// Only the room creator (or local mode) triggers phase transitions
if (shouldControlTiming) {
showInputPhase?.()
}
return
}
@@ -48,7 +55,7 @@ export function DisplayPhase() {
isProcessingRef.current = true
const card = state.quizCards[state.currentCardIndex]
console.log(
`DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number}`
`DisplayPhase: Showing card ${state.currentCardIndex + 1}/${state.quizCards.length}, number: ${card.number} (isRoomCreator: ${isRoomCreator}, shouldControlTiming: ${shouldControlTiming})`
)
// Calculate adaptive timing based on display speed
@@ -67,15 +74,26 @@ export function DisplayPhase() {
`DisplayPhase: Card ${state.currentCardIndex + 1} now visible (flash: ${flashDuration}ms, pause: ${transitionPause}ms)`
)
// Display card for specified time with adaptive transition pause
await new Promise((resolve) => setTimeout(resolve, displayTimeMs - transitionPause))
// Only the room creator (or local mode) controls the timing
if (shouldControlTiming) {
// Display card for specified time with adaptive transition pause
await new Promise((resolve) => setTimeout(resolve, displayTimeMs - transitionPause))
// Don't hide the abacus - just advance to next card for smooth transition
console.log(`DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next`)
await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition
// Don't hide the abacus - just advance to next card for smooth transition
console.log(
`DisplayPhase: Card ${state.currentCardIndex + 1} transitioning to next (controlled by ${isRoomCreator === undefined ? 'local mode' : 'room creator'})`
)
await new Promise((resolve) => setTimeout(resolve, transitionPause)) // Adaptive pause for visual transition
isProcessingRef.current = false
nextCard?.()
isProcessingRef.current = false
nextCard?.()
} else {
// Non-creator players just display the card, don't control timing
console.log(
`DisplayPhase: Non-creator player displaying card ${state.currentCardIndex + 1}, waiting for creator to advance`
)
isProcessingRef.current = false
}
}
showNextCard()
@@ -86,6 +104,8 @@ export function DisplayPhase() {
nextCard,
showInputPhase,
state.quizCards[state.currentCardIndex],
shouldControlTiming,
isRoomCreator,
])
return (

View File

@@ -10,6 +10,7 @@ export interface MemoryQuizContextValue {
// Computed values
isGameActive: boolean
isRoomCreator?: boolean // True if current user is room creator (controls timing in multiplayer)
// Action creators (to be implemented by providers)
// Local mode uses dispatch, room mode uses these action creators

View File

@@ -525,6 +525,10 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
)
}
// Determine if current user is the room creator (controls card timing)
const isRoomCreator =
roomData?.members.find((member) => member.userId === viewerId)?.isCreator || false
const contextValue: MemoryQuizContextValue = {
state: mergedState,
dispatch: () => {
@@ -534,6 +538,7 @@ export function RoomMemoryQuizProvider({ children }: { children: ReactNode }) {
isGameActive,
resetGame,
exitSession,
isRoomCreator, // Pass room creator flag to components
// Expose action creators for components to use
startQuiz,
nextCard,