Compare commits

...

11 Commits

Author SHA1 Message Date
semantic-release-bot
28fc0a14be chore(release): 4.67.2 [skip ci]
## [4.67.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.67.2) (2025-10-23)

### Performance Improvements

* **complement-race:** increase train position update frequency to 60fps ([fffaf1d](fffaf1df1d))
2025-10-23 11:24:24 +00:00
Thomas Hallock
fffaf1df1d perf(complement-race): increase train position update frequency to 60fps
Increased update intervals from 50ms (20fps) to 16ms (60fps) for smoother
train movement without using react-spring animations. Changes applied to:

- Game logic loop (useSteamJourney.ts)
- Momentum/position updates (Provider.tsx)
- Position broadcasts for multiplayer (Provider.tsx)

This resolves the regression where react-spring animations caused guest
players' trains to freeze at their starting position in multiplayer games.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-23 06:23:02 -05:00
semantic-release-bot
09df96922e chore(release): 4.67.1 [skip ci]
## [4.67.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.0...v4.67.1) (2025-10-22)

### Bug Fixes

* **complement-race:** fix react-spring interpolation TypeScript errors ([0add9e4](0add9e4ef1))
2025-10-22 19:06:35 +00:00
Thomas Hallock
0add9e4ef1 fix(complement-race): fix react-spring interpolation TypeScript errors
Fixed TypeScript errors in transform interpolation by using correct react-spring
syntax: to([spring1, spring2, spring3], (a, b, c) => ...) instead of the
incorrect spring1.to((a, b, c) => ..., spring2, spring3) syntax.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 14:05:21 -05:00
semantic-release-bot
3eb85d7d72 chore(release): 4.67.0 [skip ci]
## [4.67.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.2...v4.67.0) (2025-10-22)

### Features

* **complement-race:** add react-spring animations to ghost trains for smooth movement ([eb3700a](eb3700a57d))
2025-10-22 18:52:16 +00:00
Thomas Hallock
eb3700a57d feat(complement-race): add react-spring animations to ghost trains for smooth movement
Ghost trains now use react-spring animations to smoothly interpolate between
position updates (100ms intervals), eliminating the jerky/discrete movement.

Changes:
- Import useSpring, useSprings, and animated from @react-spring/web
- Convert locomotive and car positions to animated springs
- Use animated.g components for smooth transform interpolation
- Configure springs with tension:280, friction:60 for responsive smoothness

This provides buttery-smooth ghost train movement while receiving position
updates at 100ms intervals, fixing the "low resolution" appearance.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:50:55 -05:00
semantic-release-bot
e6c12e87e4 chore(release): 4.66.2 [skip ci]
## [4.66.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.1...v4.66.2) (2025-10-22)

### Bug Fixes

* **complement-race:** fix ghost train position update lag and reload position reset ([ad78a65](ad78a65ed7))
2025-10-22 18:44:19 +00:00
Thomas Hallock
ad78a65ed7 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>
2025-10-22 13:43:02 -05:00
Thomas Hallock
b95fc1fdff debug(complement-race): add strategic logging to trace ghost train position updates
Removed all existing debug logs and added focused logging to identify why ghost
trains only update when players stop moving.

Strategic logging added:
- [POS_BROADCAST] Logs when position broadcast interval starts/stops
- [POS_BROADCAST] Throttled logging of position broadcasts (>2% change or 5s interval)
- [POS_RECEIVED] Logs when position updates are received from other players (>2% change)

This will help identify if:
1. Position broadcasts are being sent continuously during movement
2. Position updates are being received from the server
3. Updates are being processed and applied to ghost train positions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:20:59 -05:00
semantic-release-bot
79bc0e4c80 chore(release): 4.66.1 [skip ci]
## [4.66.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.0...v4.66.1) (2025-10-22)

### Bug Fixes

* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](df60824f37))
2025-10-22 18:14:21 +00:00
Thomas Hallock
df60824f37 fix(complement-race): ensure continuous position broadcasting during train movement
Fixed an issue where ghost trains only updated when players stopped moving.

Root cause: clientPosition in useEffect dependency array caused the
position broadcasting interval to restart on every position change,
creating gaps in broadcasts during continuous movement.

Solution:
- Use useRef to track latest clientPosition without triggering effect
- Keep ref synced with position via separate useEffect
- Read position from ref inside interval callback
- Remove clientPosition from broadcasting useEffect dependencies

Now positions broadcast smoothly every 200ms regardless of movement state.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-22 13:12:59 -05:00
6 changed files with 194 additions and 86 deletions

View File

@@ -1,3 +1,38 @@
## [4.67.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.67.2) (2025-10-23)
### Performance Improvements
* **complement-race:** increase train position update frequency to 60fps ([fffaf1d](https://github.com/antialias/soroban-abacus-flashcards/commit/fffaf1df1d4d55c811bf634c957691e3564470d6))
## [4.67.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.0...v4.67.1) (2025-10-22)
### Bug Fixes
* **complement-race:** fix react-spring interpolation TypeScript errors ([0add9e4](https://github.com/antialias/soroban-abacus-flashcards/commit/0add9e4ef1d69e4e92ffe279cce09c68efa43714))
## [4.67.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.2...v4.67.0) (2025-10-22)
### Features
* **complement-race:** add react-spring animations to ghost trains for smooth movement ([eb3700a](https://github.com/antialias/soroban-abacus-flashcards/commit/eb3700a57d035a142c64b60d5d1b21181d21b69f))
## [4.66.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.1...v4.66.2) (2025-10-22)
### Bug Fixes
* **complement-race:** fix ghost train position update lag and reload position reset ([ad78a65](https://github.com/antialias/soroban-abacus-flashcards/commit/ad78a65ed7f63509602e79246e3761653ea39a15))
## [4.66.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.0...v4.66.1) (2025-10-22)
### Bug Fixes
* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](https://github.com/antialias/soroban-abacus-flashcards/commit/df60824f37f52e77e69d32c26926a24e1af88e66))
## [4.66.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.1...v4.66.0) (2025-10-22)

View File

@@ -1,6 +1,7 @@
'use client'
import { useEffect, useMemo, useRef } from 'react'
import { useSpring, useSprings, animated, to } from '@react-spring/web'
import { useMemo, useRef } from 'react'
import type { PlayerState } from '@/arcade-games/complement-race/types'
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
@@ -56,8 +57,8 @@ export function GhostTrain({
}: GhostTrainProps) {
const ghostRef = useRef<SVGGElement>(null)
// Calculate ghost train locomotive transform and opacity
const locomotiveTransform = useMemo<CarTransform | null>(() => {
// Calculate target transform for locomotive (used by spring animation)
const locomotiveTarget = useMemo<CarTransform | null>(() => {
if (!pathRef.current) {
return null
}
@@ -82,8 +83,17 @@ export function GhostTrain({
}
}, [trainPosition, localTrainCarPositions, pathRef])
// Calculate ghost train car transforms (each car behind locomotive)
const carTransforms = useMemo<CarTransform[]>(() => {
// Animated spring for smooth locomotive movement
const locomotiveSpring = useSpring({
x: locomotiveTarget?.x ?? 0,
y: locomotiveTarget?.y ?? 0,
rotation: locomotiveTarget?.rotation ?? 0,
opacity: locomotiveTarget?.opacity ?? 1,
config: { tension: 280, friction: 60 }, // Smooth but responsive
})
// Calculate target transforms for cars (used by spring animations)
const carTargets = useMemo<CarTransform[]>(() => {
if (!pathRef.current) {
return []
}
@@ -115,29 +125,32 @@ export function GhostTrain({
return cars
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
// Log only once when this ghost train first renders
const hasLoggedRef = useRef(false)
useEffect(() => {
if (!hasLoggedRef.current && locomotiveTransform) {
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
hasLoggedRef.current = true
}
}, [locomotiveTransform, player.name, trainPosition])
// Animated springs for smooth car movement (useSprings for multiple cars)
const carSprings = useSprings(
carTargets.length,
carTargets.map((target) => ({
x: target.x,
y: target.y,
rotation: target.rotation,
opacity: target.opacity,
config: { tension: 280, friction: 60 },
}))
)
// Don't render if position data isn't ready
if (!locomotiveTransform) {
if (!locomotiveTarget) {
return null
}
return (
<g ref={ghostRef} data-component="ghost-train" data-player-id={player.id}>
{/* Ghost locomotive */}
<g
transform={`translate(${locomotiveTransform.x}, ${locomotiveTransform.y}) rotate(${locomotiveTransform.rotation}) scale(-1, 1)`}
opacity={locomotiveTransform.opacity}
style={{
transition: 'opacity 0.3s ease-in-out',
}}
{/* Ghost locomotive - animated */}
<animated.g
transform={to(
[locomotiveSpring.x, locomotiveSpring.y, locomotiveSpring.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={locomotiveSpring.opacity}
>
<text
data-element="ghost-locomotive"
@@ -188,17 +201,17 @@ export function GhostTrain({
>
{player.score}
</text>
</g>
</animated.g>
{/* Ghost cars - each with individual opacity */}
{carTransforms.map((car, index) => (
<g
{/* Ghost cars - each with individual animated opacity and position */}
{carSprings.map((spring, index) => (
<animated.g
key={`car-${index}`}
transform={`translate(${car.x}, ${car.y}) rotate(${car.rotation}) scale(-1, 1)`}
opacity={car.opacity}
style={{
transition: 'opacity 0.3s ease-in-out',
}}
transform={to(
[spring.x, spring.y, spring.rotation],
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
)}
opacity={spring.opacity}
>
<text
data-element={`ghost-car-${index}`}
@@ -213,7 +226,7 @@ export function GhostTrain({
>
🚃
</text>
</g>
</animated.g>
))}
</g>
)

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'
@@ -106,16 +106,6 @@ export function SteamTrainJourney({
const localPlayer = activePlayers.find((p) => p.isLocal)
const playerEmoji = localPlayer?.emoji ?? '👤'
// Log only when localPlayer changes
useEffect(() => {
console.log(
'[SteamTrainJourney] localPlayer:',
localPlayer?.name,
'isLocal:',
localPlayer?.isLocal
)
}, [localPlayer?.id, localPlayer?.name, localPlayer?.isLocal])
const svgRef = useRef<SVGSVGElement>(null)
const pathRef = useRef<SVGPathElement>(null)
const [trackGenerator] = useState(() => new RailroadTrackGenerator(800, 600))
@@ -224,17 +214,6 @@ export function SteamTrainJourney({
return filtered
}, [multiplayerState?.players, localPlayerId])
// Log only when otherPlayers count changes
useEffect(() => {
console.log('[SteamTrainJourney] otherPlayers count:', otherPlayers.length)
if (otherPlayers.length > 0) {
console.log(
'[SteamTrainJourney] ghost positions:',
otherPlayers.map((p) => `${p.name}: ${p.position.toFixed(1)}`).join(', ')
)
}
}, [otherPlayers.length, otherPlayers])
if (!trackData) return null
return (

View File

@@ -34,7 +34,7 @@ const MOMENTUM_DECAY_RATES = {
const MOMENTUM_GAIN_PER_CORRECT = 15 // Momentum added for each correct answer
const SPEED_MULTIPLIER = 0.15 // Convert momentum to speed (% per second at momentum=100)
const UPDATE_INTERVAL = 50 // Update every 50ms (~20 fps)
const UPDATE_INTERVAL = 16 // Update every 16ms (~60 fps for smooth animation)
const GAME_DURATION = 60000 // 60 seconds in milliseconds
export function useSteamJourney() {

View File

@@ -313,18 +313,28 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
return foundId
}, [activePlayers, players])
// Log only when localPlayerId changes
useEffect(() => {
console.log('[Provider] localPlayerId:', localPlayerId)
}, [localPlayerId])
// Debug logging ref (track last logged values)
const lastLogRef = useState({ key: '', count: 0 })[0]
// Client-side game state (NOT synced to server - purely visual/gameplay)
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
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)
// Refs for throttled logging
const lastBroadcastLogRef = useRef({ position: 0, time: 0 })
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
@@ -355,7 +365,7 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const MOMENTUM_GAIN_PER_CORRECT = 15
const MOMENTUM_LOSS_PER_WRONG = 10
const SPEED_MULTIPLIER = 0.15 // momentum * 0.15 = % per second
const UPDATE_INTERVAL = 50 // 50ms = ~20fps
const UPDATE_INTERVAL = 16 // 16ms = ~60fps for smooth animation
// Transform multiplayer state to look like single-player state
const compatibleState = useMemo((): CompatibleGameState => {
@@ -451,16 +461,50 @@ 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
@@ -556,39 +600,77 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
const currentRoute = multiplayerState.currentRoute
// When route changes, reset position and give starting momentum
if (currentRoute > 1 && compatibleState.style === 'sprint') {
console.log(
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
)
setClientPosition(0)
setClientMomentum(10) // Reset to starting momentum (gentle push)
}
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
// Keep position ref in sync with latest position
useEffect(() => {
clientPositionRef.current = clientPosition
}, [clientPosition])
// Log when we receive position updates from other players
useEffect(() => {
if (!multiplayerState?.players || !localPlayerId) return
Object.entries(multiplayerState.players).forEach(([playerId, player]) => {
if (playerId === localPlayerId || !player.isActive) return
const lastPos = lastReceivedPositionsRef.current[playerId] ?? -1
const currentPos = player.position
// Log when position changes significantly (>2%)
if (Math.abs(currentPos - lastPos) > 2) {
console.log(
`[POS_RECEIVED] ${player.name}: ${currentPos.toFixed(1)}% (was ${lastPos.toFixed(1)}%, delta=${(currentPos - lastPos).toFixed(1)}%)`
)
lastReceivedPositionsRef.current[playerId] = currentPos
}
})
}, [multiplayerState?.players, localPlayerId])
// 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
}
// Send position update every 200ms
console.log('[POS_BROADCAST] Starting position broadcast interval')
// Send position update every 16ms (~60fps) for smoother ghost trains (reads from refs to avoid restarting interval)
const interval = setInterval(() => {
sendMove({
const currentPos = clientPositionRef.current
broadcastCountRef.current++
// Throttled logging: only log when position changes by >2% or every 5 seconds
const now = Date.now()
const posDiff = Math.abs(currentPos - lastBroadcastLogRef.current.position)
const timeDiff = now - lastBroadcastLogRef.current.time
if (posDiff > 2 || timeDiff > 5000) {
console.log(
`[POS_BROADCAST] #${broadcastCountRef.current} pos=${currentPos.toFixed(1)}% (delta=${posDiff.toFixed(1)}%)`
)
lastBroadcastLogRef.current = { position: currentPos, time: now }
}
sendMoveRef.current({
type: 'UPDATE_POSITION',
playerId: localPlayerId,
userId: viewerId || '',
data: { position: clientPosition },
data: { position: currentPos },
} as ComplementRaceMove)
}, 200)
}, 16)
return () => clearInterval(interval)
}, [
compatibleState.isGameActive,
compatibleState.style,
clientPosition,
localPlayerId,
viewerId,
sendMove,
])
return () => {
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)
clearInterval(interval)
}
}, [multiplayerState.gamePhase, multiplayerState.config.style, localPlayerId, viewerId])
// Keep lastLogRef for future debugging needs
// (removed debug logging)
@@ -803,7 +885,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
case 'START_NEW_ROUTE':
// Send route progression to server
if (action.routeNumber !== undefined) {
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
sendMove({
type: 'START_NEW_ROUTE',
playerId: activePlayers[0] || '',

View File

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