fix(complement-race): fix ghost train position update lag and reload position reset

Fixed three critical multiplayer issues:

1. **Fixed interval restart bug**: Position broadcast interval was constantly restarting
   because useEffect depended on `compatibleState`, which changed on every position
   update. Now uses stable dependencies (`multiplayerState.gamePhase`, etc.)

2. **Increased broadcast frequency**: Changed from 200ms (5 Hz) to 100ms (10 Hz)
   for smoother ghost train movement during multiplayer races

3. **Fixed position reset on reload**: Client position now syncs from server's
   authoritative position when browser reloads, preventing trains from resetting
   to start of track

Additional fixes:
- Used refs for `sendMove` to prevent interval recreation
- Removed unused imports (useEffect from GhostTrain, SteamTrainJourney)
- Added strategic logging for position broadcast and reception

🤖 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-22 13:43:02 -05:00
parent b95fc1fdff
commit ad78a65ed7
3 changed files with 52 additions and 11 deletions

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { useMemo, useRef } from 'react'
import type { PlayerState } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'

View File

@ -1,7 +1,7 @@
'use client'
import { animated, useSpring } from '@react-spring/web'
import { memo, useEffect, useMemo, useRef, useState } from 'react'
import { memo, useMemo, useRef, useState } from 'react'
import { useGameMode } from '@/contexts/GameModeContext'
import { useUserProfile } from '@/contexts/UserProfileContext'
import { useComplementRace } from '@/arcade-games/complement-race/Provider'

View File

@ -318,6 +318,9 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const [clientPosition, setClientPosition] = useState(0)
const [clientPressure, setClientPressure] = useState(0)
// Track if we've synced position from server (for reconnect/reload scenarios)
const hasInitializedPositionRef = useRef(false)
// Ref to track latest position for broadcasting (avoids recreating interval on every position change)
const clientPositionRef = useRef(clientPosition)
@ -326,6 +329,12 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const broadcastCountRef = useRef(0)
const lastReceivedPositionsRef = useRef<Record<string, number>>({})
// Ref to hold sendMove so interval doesn't restart when sendMove changes
const sendMoveRef = useRef(sendMove)
useEffect(() => {
sendMoveRef.current = sendMove
}, [sendMove])
const [clientAIRacers, setClientAIRacers] = useState<
Array<{
id: string
@ -452,16 +461,45 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
clientAIRacers,
])
// Sync client position from server on reconnect/reload (multiplayer only)
useEffect(() => {
// Only sync if:
// 1. We haven't synced yet
// 2. Game is active
// 3. We're in sprint mode
// 4. We have a local player with a position from server
if (
!hasInitializedPositionRef.current &&
multiplayerState.gamePhase === 'playing' &&
multiplayerState.config.style === 'sprint' &&
localPlayerId
) {
const serverPosition = multiplayerState.players[localPlayerId]?.position
if (serverPosition !== undefined && serverPosition > 0) {
console.log(`[POSITION_SYNC] Restoring position from server: ${serverPosition.toFixed(1)}%`)
setClientPosition(serverPosition)
hasInitializedPositionRef.current = true
}
}
// Reset sync flag when game ends
if (multiplayerState.gamePhase !== 'playing') {
hasInitializedPositionRef.current = false
}
}, [multiplayerState.gamePhase, multiplayerState.config.style, multiplayerState.players, localPlayerId])
// Initialize game start time when game becomes active
useEffect(() => {
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
if (gameStartTimeRef.current === 0) {
gameStartTimeRef.current = Date.now()
lastUpdateRef.current = Date.now()
// Reset client state for new game
setClientMomentum(10) // Start with gentle push
setClientPosition(0)
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
// Reset client state for new game (only if not restored from server)
if (!hasInitializedPositionRef.current) {
setClientMomentum(10) // Start with gentle push
setClientPosition(0)
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
}
}
} else {
// Reset when game ends
@ -589,13 +627,16 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
// Broadcast position to server for multiplayer ghost trains
useEffect(() => {
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint' || !localPlayerId) {
const isGameActive = multiplayerState.gamePhase === 'playing'
const isSprint = multiplayerState.config.style === 'sprint'
if (!isGameActive || !isSprint || !localPlayerId) {
return
}
console.log('[POS_BROADCAST] Starting position broadcast interval')
// Send position update every 200ms (reads from ref to avoid restarting interval)
// Send position update every 100ms for smoother ghost trains (reads from refs to avoid restarting interval)
const interval = setInterval(() => {
const currentPos = clientPositionRef.current
broadcastCountRef.current++
@ -612,19 +653,19 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
lastBroadcastLogRef.current = { position: currentPos, time: now }
}
sendMove({
sendMoveRef.current({
type: 'UPDATE_POSITION',
playerId: localPlayerId,
userId: viewerId || '',
data: { position: currentPos },
} as ComplementRaceMove)
}, 200)
}, 100)
return () => {
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)
clearInterval(interval)
}
}, [compatibleState.isGameActive, compatibleState.style, localPlayerId, viewerId, sendMove])
}, [multiplayerState.gamePhase, multiplayerState.config.style, localPlayerId, viewerId])
// Keep lastLogRef for future debugging needs
// (removed debug logging)