From ad78a65ed7f63509602e79246e3761653ea39a15 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 22 Oct 2025 13:43:02 -0500 Subject: [PATCH] fix(complement-race): fix ghost train position update lag and reload position reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../components/RaceTrack/GhostTrain.tsx | 2 +- .../RaceTrack/SteamTrainJourney.tsx | 2 +- .../arcade-games/complement-race/Provider.tsx | 59 ++++++++++++++++--- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx index 93b5ae48..9752150b 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/GhostTrain.tsx @@ -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' diff --git a/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx b/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx index 7b4c4665..f448ba1e 100644 --- a/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx +++ b/apps/web/src/app/arcade/complement-race/components/RaceTrack/SteamTrainJourney.tsx @@ -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' diff --git a/apps/web/src/arcade-games/complement-race/Provider.tsx b/apps/web/src/arcade-games/complement-race/Provider.tsx index ca13cfab..7ee546c3 100644 --- a/apps/web/src/arcade-games/complement-race/Provider.tsx +++ b/apps/web/src/arcade-games/complement-race/Provider.tsx @@ -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>({}) + // 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)