diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 5a4135a..5edc1e7 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: read + pull-requests: write issues: read id-token: write diff --git a/.gitignore b/.gitignore index 4ff932d..9568bb3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,10 @@ Thumbs.db # Build artifacts *.tsbuildinfo .cache/ +shared/src/*.js +shared/src/*.js.map +shared/src/*.d.ts +shared/src/*.d.ts.map # Debug .vscode-test/ diff --git a/client/src/network/NetworkManager.ts b/client/src/network/NetworkManager.ts index 9a779ea..39aed5d 100644 --- a/client/src/network/NetworkManager.ts +++ b/client/src/network/NetworkManager.ts @@ -348,7 +348,7 @@ export class NetworkManager { this.connected = false this.room = undefined } - + this.inputBuffer.clear() this.onStateChange = undefined @@ -453,6 +453,7 @@ export class NetworkManager { tryHookPlayers(state) if (!state || !state.ball || !state.players) return + // Build GameStateData for onStateChange consumers const gameState: GameStateData = { matchTime: state.matchTime || 0, scoreBlue: state.scoreBlue || 0, @@ -460,28 +461,19 @@ export class NetworkManager { phase: state.phase || 'waiting', players: new Map(), ball: { - x: state.ball.x || 0, - y: state.ball.y || 0, - velocityX: state.ball.velocityX || 0, - velocityY: state.ball.velocityY || 0, - possessedBy: state.ball.possessedBy || '', - pressureLevel: state.ball.pressureLevel || 0, + x: state.ball.x || 0, y: state.ball.y || 0, + velocityX: state.ball.velocityX || 0, velocityY: state.ball.velocityY || 0, + possessedBy: state.ball.possessedBy || '', pressureLevel: state.ball.pressureLevel || 0, }, } - - state.players.forEach((player: ColyseusPlayer, key: string) => { + state.players.forEach((p: ColyseusPlayer, key: string) => { gameState.players.set(key, { - id: player.id || key, - team: player.team || 'blue', - x: player.x || 0, - y: player.y || 0, - velocityX: player.velocityX || 0, - velocityY: player.velocityY || 0, - state: player.state || 'idle', - direction: player.direction || 0, + id: p.id || key, team: p.team || 'blue', + x: p.x || 0, y: p.y || 0, + velocityX: p.velocityX || 0, velocityY: p.velocityY || 0, + state: p.state || 'idle', direction: p.direction || 0, }) }) - this.onStateChange?.(gameState) }) } @@ -538,7 +530,9 @@ export class NetworkManager { getSessionId(): string { return this.sessionId } getRoom(): Room | undefined { return this.room } getMySessionId(): string { return this.sessionId } - getState(): ColyseusGameState | undefined { return this.room?.state } + getState(): ColyseusGameState | undefined { + return this.room?.state + } private emitRoomClosed(reason: string) { const listeners = [...this.roomClosedListeners] diff --git a/client/src/scenes/BaseGameScene.ts b/client/src/scenes/BaseGameScene.ts index 24e6c6d..630f6cb 100644 --- a/client/src/scenes/BaseGameScene.ts +++ b/client/src/scenes/BaseGameScene.ts @@ -29,6 +29,7 @@ export abstract class BaseGameScene extends PixiScene { protected ball!: Graphics protected ballShadow!: Graphics protected controlArrow?: Graphics + private controlArrowDrawn: boolean = false // UI elements protected scoreboardContainer!: Container @@ -216,26 +217,30 @@ export abstract class BaseGameScene extends PixiScene { } } - // Update broadcast-style scoreboard - if (this.blueScoreText) this.blueScoreText.text = `${state.scoreBlue}` - if (this.redScoreText) this.redScoreText.text = `${state.scoreRed}` + // Update scoreboard — only set .text when value changes to avoid expensive + // PixiJS Text re-renders (canvas draw + GPU texture upload on every change). + const blueStr = `${state.scoreBlue}` + const redStr = `${state.scoreRed}` + if (this.blueScoreText && this.blueScoreText.text !== blueStr) this.blueScoreText.text = blueStr + if (this.redScoreText && this.redScoreText.text !== redStr) this.redScoreText.text = redStr const minutes = Math.floor(state.matchTime / 60) const seconds = Math.floor(state.matchTime % 60) - this.timerText.text = `${minutes}:${seconds.toString().padStart(2, '0')}` + const timerStr = `${minutes}:${seconds.toString().padStart(2, '0')}` + if (this.timerText.text !== timerStr) this.timerText.text = timerStr - // Timer urgency effect in last 30 seconds + // Timer urgency effect in last 30 seconds (guard style.fill to avoid re-renders) if (state.matchTime <= 30 && state.matchTime > 0) { - this.timerText.style.fill = '#ff5252' + if (this.timerText.style.fill !== '#ff5252') this.timerText.style.fill = '#ff5252' if (this.timerBg) { this.timerBg.tint = 0xff5252 - this.timerBg.alpha = 0.15 + Math.sin(Date.now() / 200) * 0.05 // Subtle pulse + this.timerBg.alpha = 0.15 + Math.sin(Date.now() / 200) * 0.05 } } else { - this.timerText.style.fill = '#ffffff' + if (this.timerText.style.fill !== '#ffffff') this.timerText.style.fill = '#ffffff' if (this.timerBg) { - this.timerBg.tint = 0xffffff - this.timerBg.alpha = 1 + if (this.timerBg.tint !== 0xffffff) this.timerBg.tint = 0xffffff + if (this.timerBg.alpha !== 1) this.timerBg.alpha = 1 } } } @@ -338,6 +343,7 @@ export abstract class BaseGameScene extends PixiScene { this.controlArrow = new Graphics() this.controlArrow.zIndex = 11 this.controlArrow.visible = false + this.controlArrowDrawn = false this.cameraManager.getGameContainer().addChild(this.controlArrow) } } @@ -563,21 +569,18 @@ export abstract class BaseGameScene extends PixiScene { const unifiedState = this.getUnifiedState() if (!unifiedState || !this.controlledPlayerId) { - this.controlArrow.clear() this.controlArrow.visible = false return } const playerState = unifiedState.players.get(this.controlledPlayerId) if (!playerState) { - this.controlArrow.clear() this.controlArrow.visible = false return } const sprite = this.players.get(this.controlledPlayerId) if (!sprite) { - this.controlArrow.clear() this.controlArrow.visible = false return } @@ -586,7 +589,6 @@ export abstract class BaseGameScene extends PixiScene { const vy = playerState.velocityY ?? 0 if (isNaN(vx) || isNaN(vy) || !isFinite(vx) || !isFinite(vy)) { - this.controlArrow.clear() this.controlArrow.visible = false return } @@ -595,48 +597,36 @@ export abstract class BaseGameScene extends PixiScene { const MIN_SPEED_THRESHOLD = 15 if (speed < MIN_SPEED_THRESHOLD) { - this.controlArrow.clear() this.controlArrow.visible = false return } const direction = playerState.direction if (direction === undefined || direction === null || Number.isNaN(direction)) { - this.controlArrow.clear() this.controlArrow.visible = false return } - const radius = GAME_CONFIG.PLAYER_RADIUS - const baseDistance = radius + 12 - const tipDistance = baseDistance + 24 - const baseHalfWidth = 18 - - const dirX = Math.cos(direction) - const dirY = Math.sin(direction) - const perpX = Math.cos(direction + Math.PI / 2) - const perpY = Math.sin(direction + Math.PI / 2) - - const baseCenterX = sprite.x + dirX * baseDistance - const baseCenterY = sprite.y + dirY * baseDistance - const tipX = sprite.x + dirX * tipDistance - const tipY = sprite.y + dirY * tipDistance - - const baseLeftX = baseCenterX + perpX * baseHalfWidth - const baseLeftY = baseCenterY + perpY * baseHalfWidth - const baseRightX = baseCenterX - perpX * baseHalfWidth - const baseRightY = baseCenterY - perpY * baseHalfWidth + // Draw the arrow shape once at the origin; afterwards just move + rotate + if (!this.controlArrowDrawn) { + const radius = GAME_CONFIG.PLAYER_RADIUS + const baseDistance = radius + 12 + const tipDistance = baseDistance + 24 + const baseHalfWidth = 18 + + // Arrow points along +X (rotation=0 means pointing right) + this.controlArrow.moveTo(tipDistance, 0) + this.controlArrow.lineTo(baseDistance, baseHalfWidth) + this.controlArrow.moveTo(tipDistance, 0) + this.controlArrow.lineTo(baseDistance, -baseHalfWidth) + this.controlArrow.stroke({ width: 4, color: 0xffffff, alpha: 0.95 }) + this.controlArrowDrawn = true + } - this.controlArrow.clear() + // Position at player sprite and rotate to match direction — no clear/redraw needed + this.controlArrow.position.set(sprite.x, sprite.y) + this.controlArrow.rotation = direction this.controlArrow.visible = true - - this.controlArrow.moveTo(tipX, tipY) - this.controlArrow.lineTo(baseLeftX, baseLeftY) - - this.controlArrow.moveTo(tipX, tipY) - this.controlArrow.lineTo(baseRightX, baseRightY) - - this.controlArrow.stroke({ width: 4, color: 0xffffff, alpha: 0.95 }) } protected updateBallColor(state: any) { @@ -1019,6 +1009,11 @@ export abstract class BaseGameScene extends PixiScene { if (this.joystick) this.joystick.destroy() if (this.actionButton) this.actionButton.destroy() + if (this.controlArrow) { + this.controlArrow.destroy() + this.controlArrow = undefined + this.controlArrowDrawn = false + } if (this.cameraManager) this.cameraManager.destroy() if (this.aiDebugRenderer) this.aiDebugRenderer.destroy() diff --git a/client/src/scenes/GameSceneConstants.ts b/client/src/scenes/GameSceneConstants.ts index b91bcac..55c2a28 100644 --- a/client/src/scenes/GameSceneConstants.ts +++ b/client/src/scenes/GameSceneConstants.ts @@ -7,14 +7,10 @@ export const VISUAL_CONSTANTS = { CONTROLLED_PLAYER_BORDER: 6, // Increased thicker border for controlled player UNCONTROLLED_PLAYER_BORDER: 3, // Slightly thicker for better visibility - // Interpolation factors (reduced for lower latency) - BALL_LERP_FACTOR: 0.5, // Increased from 0.3 for faster ball sync - REMOTE_PLAYER_LERP_FACTOR: 0.5, // Increased from 0.3 for faster remote player sync - - // Reconciliation factors (increased for faster correction) - BASE_RECONCILE_FACTOR: 0.2, // Increased from 0.05 for faster correction - MODERATE_RECONCILE_FACTOR: 0.5, // Increased from 0.3 - STRONG_RECONCILE_FACTOR: 0.8, // Increased from 0.6 + // Reconciliation factors — raised to correct local player errors faster at 60Hz. + BASE_RECONCILE_FACTOR: 0.35, + MODERATE_RECONCILE_FACTOR: 0.6, + STRONG_RECONCILE_FACTOR: 0.8, // Error thresholds for reconciliation MODERATE_ERROR_THRESHOLD: 25, @@ -24,10 +20,6 @@ export const VISUAL_CONSTANTS = { MIN_MOVEMENT_INPUT: 0.01, MIN_POSITION_CHANGE: 0.5, MIN_CORRECTION: 2, - REMOTE_MOVEMENT_THRESHOLD: 1, - - // Debug logging - STATE_UPDATE_LOG_INTERVAL: 60, // Log every 60 updates (~2 seconds at 30fps) // Team colors (darkened for ball) BALL_BLUE_COLOR: 0x0047b3, diff --git a/client/src/scenes/MultiplayerScene.ts b/client/src/scenes/MultiplayerScene.ts index e761eb7..c2a0fa8 100644 --- a/client/src/scenes/MultiplayerScene.ts +++ b/client/src/scenes/MultiplayerScene.ts @@ -20,13 +20,26 @@ export class MultiplayerScene extends BaseGameScene { protected mySessionId?: string private isMultiplayer: boolean = false private roomDebugText!: Text - private stateUpdateCount: number = 0 private colorInitialized: boolean = false private positionInitialized: boolean = false private returningToMenu: boolean = false private aiEnabled: boolean = true private lastControlledPlayerId?: string private lastMovementWasNonZero: boolean = false + // Dead reckoning state — track last known server positions + velocities so we can + // extrapolate between patches and eliminate visual stalls during network jitter + private lastBallServerX: number = 0 + private lastBallServerY: number = 0 + private lastBallServerVX: number = 0 + private lastBallServerVY: number = 0 + private lastBallStateReceivedAt: number = 0 + private lastRemotePlayerStates = new Map() + + // Per-frame cache for getUnifiedState() — avoids allocating a new Map 3+ times per frame + private _cachedUnifiedState: GameEngineState | null = null + private _cachedUnifiedStateFrame: number = -1 + private _frameCounter: number = 0 + private frameDeltaS: number = 0 constructor(app: Application, key: string, manager: PixiSceneManager) { super(app, key, manager) @@ -45,7 +58,6 @@ export class MultiplayerScene extends BaseGameScene { this.mySessionId = undefined this.colorInitialized = false this.positionInitialized = false - this.stateUpdateCount = 0 this.lastControlledPlayerId = undefined this.lastMovementWasNonZero = false @@ -61,8 +73,12 @@ export class MultiplayerScene extends BaseGameScene { return } + // Advance frame counter so getUnifiedState() cache invalidates each frame + this._frameCounter++ + this.frameDeltaS = delta / 1000 + try { - const dt = delta / 1000 // Convert to seconds assuming delta is MS. + const dt = this.frameDeltaS const currentControlledId = this.controlledPlayerId if (this.lastControlledPlayerId && this.lastControlledPlayerId !== currentControlledId) { @@ -89,6 +105,7 @@ export class MultiplayerScene extends BaseGameScene { const controlledSprite = this.players.get(this.controlledPlayerId) if (controlledSprite) { + // Instant velocity — matches server's playerAcceleration: 1 (MatchRoom GameEngine config) controlledSprite.x += movement.x * GAME_CONFIG.PLAYER_SPEED * dt controlledSprite.y += movement.y * GAME_CONFIG.PLAYER_SPEED * dt @@ -99,11 +116,6 @@ export class MultiplayerScene extends BaseGameScene { } if (this.controlledPlayerId) { - const movement = this.collectMovementInput() - const hasMovement = - Math.abs(movement.x) > VISUAL_CONSTANTS.MIN_MOVEMENT_INPUT || - Math.abs(movement.y) > VISUAL_CONSTANTS.MIN_MOVEMENT_INPUT - if (hasMovement && this.networkManager.isConnected()) { this.networkManager.sendInput( movement, @@ -184,13 +196,32 @@ export class MultiplayerScene extends BaseGameScene { this.aiManager = undefined } + this.lastRemotePlayerStates.clear() + this.lastBallStateReceivedAt = 0 + this.lastBallServerX = 0 + this.lastBallServerY = 0 + this.lastBallServerVX = 0 + this.lastBallServerVY = 0 + this._cachedUnifiedState = null + this._cachedUnifiedStateFrame = -1 + this._frameCounter = 0 console.log('✅ [MultiplayerScene] Cleanup complete - disconnected and game stopped') } protected getUnifiedState(): GameEngineState | null { + // Return cached result if already computed this frame (avoids 3+ Map allocations per frame) + if (this._cachedUnifiedStateFrame === this._frameCounter) { + return this._cachedUnifiedState + } const rawState = this.networkManager?.getState() - if (!rawState) return null - return this.fromNetwork(rawState) + if (!rawState) { + this._cachedUnifiedState = null + this._cachedUnifiedStateFrame = this._frameCounter + return null + } + this._cachedUnifiedState = this.fromNetwork(rawState) + this._cachedUnifiedStateFrame = this._frameCounter + return this._cachedUnifiedState } /** @@ -391,7 +422,7 @@ export class MultiplayerScene extends BaseGameScene { if (!this.players.has(player.id)) { this.createPlayerSprite(player.id, player.x, player.y, player.team) } - this.initializeAI() + if (!this.aiManager) this.initializeAI() } catch (error) { console.error('[MultiplayerScene] Error handling playerJoin:', error) } @@ -401,24 +432,28 @@ export class MultiplayerScene extends BaseGameScene { try { console.log('👋 Remote player left:', playerId) this.removeRemotePlayer(playerId) - this.initializeAI() + if (!this.aiManager) this.initializeAI() } catch (error) { console.error('[MultiplayerScene] Error handling playerLeave:', error) } }) - this.networkManager.on('stateChange', (state: any) => { + this.networkManager.on('stateChange', (_state: any) => { try { - if (state?.players) { - state.players.forEach((player: any, playerId: string) => { - if (!this.players.has(playerId)) { - console.log(`🎭 Creating player sprite from state: ${playerId} (${player.team})`) - this.createPlayerSprite(playerId, player.x, player.y, player.team) - } - }) - } + // After initialization, use the live Colyseus state directly to avoid + // the Map allocation that NetworkManager.onStateChange creates every patch. + const state = this.networkManager?.getState() as any + if (!state?.players) return + + // Create sprites for any new players + state.players.forEach((player: any, playerId: string) => { + if (!this.players.has(playerId)) { + console.log(`🎭 Creating player sprite from state: ${playerId} (${player.team})`) + this.createPlayerSprite(playerId, player.x, player.y, player.team) + } + }) - if (!this.colorInitialized && state?.players?.has(this.myPlayerId)) { + if (!this.colorInitialized && state.players.has(this.myPlayerId)) { console.log(`🎨 [Init] Initializing colors (colorInitialized=${this.colorInitialized})`) this.updateLocalPlayerColor() @@ -429,18 +464,18 @@ export class MultiplayerScene extends BaseGameScene { this.colorInitialized = true console.log(`🎨 [Init] Color initialization complete`) - + this.initializeControlArrow() this.updatePlayerBorders() } - + if (!this.myPlayerId && this.mySessionId) { console.warn('[MultiplayerScene] myPlayerId not set, initializing from sessionId') this.myPlayerId = `${this.mySessionId}-p1` this.controlledPlayerId = `${this.mySessionId}-p1` } - - if (this.colorInitialized && state?.players?.size > 0) { + + if (this.colorInitialized && state.players.size > 0 && !this.aiManager) { this.initializeAI() } } catch (error) { @@ -504,10 +539,13 @@ export class MultiplayerScene extends BaseGameScene { this.returningToMenu = true console.log(`🔙 Returning to menu: ${message}`) - // Disconnect immediately to allow server to clean up session while user sees the message + // Disconnect immediately to allow server to clean up session while user sees the message. + // Set isMultiplayer = false to stop the update loop from calling sendInput/flushInputs + // on the now-disconnected networkManager during the 2-second navigation delay. if (this.networkManager) { console.log('🔌 [ReturnToMenu] Disconnecting early to facilitate cleanup') this.networkManager.disconnect() + this.isMultiplayer = false } setTimeout(() => { @@ -521,6 +559,7 @@ export class MultiplayerScene extends BaseGameScene { sprite.destroy() this.players.delete(sessionId) } + this.lastRemotePlayerStates.delete(sessionId) console.log('🗑️ Remote player removed:', sessionId) } @@ -529,21 +568,79 @@ export class MultiplayerScene extends BaseGameScene { return } - if (!this.stateUpdateCount) this.stateUpdateCount = 0 - this.stateUpdateCount++ + const serverBall = state.ball + + // Update dead-reckoning snapshot and timestamp when ball state changes. + // Timestamp tracks when the last *new* ball data arrived, so dead-reckoning + // can extrapolate forward between patches. Stationary balls (v=0) produce + // zero displacement regardless of dt, so a stale timestamp is harmless. + if ( + serverBall.x !== this.lastBallServerX || + serverBall.y !== this.lastBallServerY || + serverBall.velocityX !== this.lastBallServerVX || + serverBall.velocityY !== this.lastBallServerVY + ) { + this.lastBallServerX = serverBall.x + this.lastBallServerY = serverBall.y + this.lastBallServerVX = serverBall.velocityX + this.lastBallServerVY = serverBall.velocityY + this.lastBallStateReceivedAt = performance.now() + } - if (state.ball) { - if (this.ball.x == null || this.ball.y == null || isNaN(this.ball.x) || isNaN(this.ball.y)) { - this.ball.x = state.ball.x - this.ball.y = state.ball.y + if (this.ball.x == null || this.ball.y == null || isNaN(this.ball.x) || isNaN(this.ball.y)) { + this.ball.x = serverBall.x + this.ball.y = serverBall.y + } else if (serverBall.possessedBy) { + // Ball is possessed — lock it to the possessing player's SPRITE position + // instead of lerping toward the server ball position. The server places + // the ball at player + direction * POSSESSION_BALL_OFFSET, but the player + // sprite is predicted ahead of the server (dead reckoning / client prediction). + // Lerping would make the ball visibly trail the player. + const possessorSprite = this.players.get(serverBall.possessedBy) + if (possessorSprite) { + // Use the server ball position relative to the server player position + // to compute the offset, then apply it to the local sprite position. + const possessorState = state.players?.get(serverBall.possessedBy) + if (possessorState) { + const offsetX = serverBall.x - possessorState.x + const offsetY = serverBall.y - possessorState.y + this.ball.x = possessorSprite.x + offsetX + this.ball.y = possessorSprite.y + offsetY + } else { + this.ball.x = serverBall.x + this.ball.y = serverBall.y + } } else { - const lerpFactor = VISUAL_CONSTANTS.BALL_LERP_FACTOR - this.ball.x += (state.ball.x - this.ball.x) * lerpFactor - this.ball.y += (state.ball.y - this.ball.y) * lerpFactor + this.ball.x = serverBall.x + this.ball.y = serverBall.y + } + } else { + // Ball is free — dead reckoning extrapolation (no lerp). + // At 60Hz patches, corrections are ~1-3 px — invisible without smoothing. + // Adding a lerp chase creates a sawtooth: the ball lags behind the + // extrapolated target, then when a patch corrects the target backward + // the ball stalls momentarily. Direct assignment avoids this. + let targetX = this.lastBallServerX + let targetY = this.lastBallServerY + + if (this.lastBallStateReceivedAt > 0) { + const dtS = Math.min((performance.now() - this.lastBallStateReceivedAt) / 1000, 0.1) // cap at 100ms + // Integrate velocity with exponential friction decay: v(t) = v0 * f^(t*60) + // Displacement = integral of v(t) dt = v0 * (f^(t*60) - 1) / (60 * ln(f)) + const steps = dtS * 60 + const logF = Math.log(GAME_CONFIG.BALL_FRICTION) // ln(0.98) ≈ -0.0202 + const displacement = (Math.pow(GAME_CONFIG.BALL_FRICTION, steps) - 1) / (60 * logF) + targetX = this.lastBallServerX + this.lastBallServerVX * displacement + targetY = this.lastBallServerY + this.lastBallServerVY * displacement + targetX = Math.max(0, Math.min(targetX, GAME_CONFIG.FIELD_WIDTH)) + targetY = Math.max(0, Math.min(targetY, GAME_CONFIG.FIELD_HEIGHT)) } - this.ballShadow.x = this.ball.x + 2 - this.ballShadow.y = this.ball.y + 3 + + this.ball.x = targetX + this.ball.y = targetY } + this.ballShadow.x = this.ball.x + 2 + this.ballShadow.y = this.ball.y + 3 state.players.forEach((player: any, playerId: string) => { if (playerId === this.myPlayerId) { @@ -553,28 +650,34 @@ export class MultiplayerScene extends BaseGameScene { } }) - // Update broadcast-style scoreboard - if (this.blueScoreText) this.blueScoreText.text = `${state.scoreBlue}` - if (this.redScoreText) this.redScoreText.text = `${state.scoreRed}` + // Update scoreboard — only set .text when value changes to avoid expensive + // PixiJS Text re-renders (canvas draw + GPU texture upload on every change). + // The timer text changes every 1 second, which was causing a periodic frame drop. + const blueStr = `${state.scoreBlue}` + const redStr = `${state.scoreRed}` + if (this.blueScoreText && this.blueScoreText.text !== blueStr) this.blueScoreText.text = blueStr + if (this.redScoreText && this.redScoreText.text !== redStr) this.redScoreText.text = redStr const minutes = Math.floor(state.matchTime / 60) const seconds = Math.floor(state.matchTime % 60) - this.timerText.text = `${minutes}:${seconds.toString().padStart(2, '0')}` + const timerStr = `${minutes}:${seconds.toString().padStart(2, '0')}` + if (this.timerText.text !== timerStr) this.timerText.text = timerStr - // Timer urgency effect in last 30 seconds + // Timer urgency effect in last 30 seconds (guard style.fill to avoid re-renders) if (state.matchTime <= 30 && state.matchTime > 0) { - this.timerText.style.fill = '#ff5252' + if (this.timerText.style.fill !== '#ff5252') this.timerText.style.fill = '#ff5252' if (this.timerBg) { this.timerBg.tint = 0xff5252 this.timerBg.alpha = 0.15 + Math.sin(Date.now() / 200) * 0.05 } } else { - this.timerText.style.fill = '#ffffff' + if (this.timerText.style.fill !== '#ffffff') this.timerText.style.fill = '#ffffff' if (this.timerBg) { this.timerBg.tint = 0xffffff this.timerBg.alpha = 1 } } + } private reconcileLocalPlayer(playerState: any) { @@ -586,6 +689,13 @@ export class MultiplayerScene extends BaseGameScene { const deltaX = Math.abs(myPlayerSprite.x - serverX) const deltaY = Math.abs(myPlayerSprite.y - serverY) + // Dead zone: skip reconciliation when error is below MIN_CORRECTION. + // Small errors are normal (client prediction is slightly ahead of server). + // Reconciling tiny errors every frame creates a constant pull-back jitter. + if (deltaX < VISUAL_CONSTANTS.MIN_CORRECTION && deltaY < VISUAL_CONSTANTS.MIN_CORRECTION) { + return + } + let reconcileFactor: number = VISUAL_CONSTANTS.BASE_RECONCILE_FACTOR if (deltaX > VISUAL_CONSTANTS.LARGE_ERROR_THRESHOLD || deltaY > VISUAL_CONSTANTS.LARGE_ERROR_THRESHOLD) { @@ -611,9 +721,42 @@ export class MultiplayerScene extends BaseGameScene { if (!sprite) return } - const lerpFactor = VISUAL_CONSTANTS.REMOTE_PLAYER_LERP_FACTOR - sprite.x += (playerState.x - sprite.x) * lerpFactor - sprite.y += (playerState.y - sprite.y) * lerpFactor + const pvx = playerState.velocityX ?? 0 + const pvy = playerState.velocityY ?? 0 + let cached = this.lastRemotePlayerStates.get(sessionId) + + // First snapshot — snap directly to server position + if (!cached) { + sprite.x = playerState.x + sprite.y = playerState.y + this.lastRemotePlayerStates.set(sessionId, { + x: playerState.x, y: playerState.y, vx: pvx, vy: pvy, + lastReceivedAt: performance.now(), + }) + return + } + + // Detect when server sends a new snapshot (position or velocity changed) + if (cached.x !== playerState.x || cached.y !== playerState.y || cached.vx !== pvx || cached.vy !== pvy) { + cached.x = playerState.x + cached.y = playerState.y + cached.vx = pvx + cached.vy = pvy + cached.lastReceivedAt = performance.now() + } + + // Pure extrapolation from last known server position + velocity. + // The sprite is always at: serverPos + velocity * timeSinceSnapshot. + // This eliminates the steady-state ~12px error that velocity + 50% error + // correction created (the decay couldn't converge when snapshots arrived + // every frame, producing errX = velocity_per_frame / correction_rate). + const dtS = Math.min((performance.now() - cached.lastReceivedAt) / 1000, 0.1) + sprite.x = cached.x + cached.vx * dtS + sprite.y = cached.y + cached.vy * dtS + + // Clamp to field bounds + sprite.x = Math.max(0, Math.min(sprite.x, GAME_CONFIG.FIELD_WIDTH)) + sprite.y = Math.max(0, Math.min(sprite.y, GAME_CONFIG.FIELD_HEIGHT)) } private updateLocalPlayerColor() { @@ -680,32 +823,26 @@ export class MultiplayerScene extends BaseGameScene { const myTeam: 'blue' | 'red' = localPlayer.team - const myTeamPlayerIds: string[] = [] - const opponentTeamPlayerIds: string[] = [] + const bluePlayerIds: string[] = [] + const redPlayerIds: string[] = [] state.players.forEach((player: any, playerId: string) => { - if (player.team === myTeam) { - myTeamPlayerIds.push(playerId) + if (player.team === 'blue') { + bluePlayerIds.push(playerId) + } else { + redPlayerIds.push(playerId) } }) if (!this.aiManager) { this.aiManager = new AIManager() } - - if (myTeam === 'blue') { - this.aiManager.initialize( - myTeamPlayerIds, - opponentTeamPlayerIds, - (playerId, decision) => this.applyAIDecision(playerId, decision) - ) - } else { - this.aiManager.initialize( - opponentTeamPlayerIds, - myTeamPlayerIds, - (playerId, decision) => this.applyAIDecision(playerId, decision) - ) - } + + this.aiManager.initialize( + bluePlayerIds, + redPlayerIds, + (playerId, decision) => this.applyAIDecision(playerId, decision) + ) console.log(`🤖 AI initialized for ${myTeam} team`) } @@ -764,8 +901,10 @@ export class MultiplayerScene extends BaseGameScene { return super.shouldAllowAIControl(playerId) } + // Block AI control of the human-controlled player if (playerId === this.controlledPlayerId) return false - if (playerId === this.mySessionId) return true + // Allow AI control of bots belonging to this client's session (format: "-p2", "-p3") + if (this.mySessionId && playerId.startsWith(`${this.mySessionId}-`)) return true return super.shouldAllowAIControl(playerId) } diff --git a/client/src/types/global.d.ts b/client/src/types/global.d.ts index a74a276..1529471 100644 --- a/client/src/types/global.d.ts +++ b/client/src/types/global.d.ts @@ -9,7 +9,6 @@ import type { VirtualJoystick } from '@/controls/VirtualJoystick' import type { ActionButton } from '@/controls/ActionButton' import type { gameClock } from '@shared/engine/GameClock' import type { PixiScene } from '@/utils/PixiScene' - declare global { interface Window { // Menu scene flag diff --git a/server/src/rooms/MatchRoom.ts b/server/src/rooms/MatchRoom.ts index 50f51d4..c0c111a 100644 --- a/server/src/rooms/MatchRoom.ts +++ b/server/src/rooms/MatchRoom.ts @@ -3,13 +3,12 @@ import { GameState } from '../schema/GameState.js' import { gameClock } from '@kickoff/shared/engine/GameClock' import type { MatchRoomOptions, PlayerReadyEvent, MatchStartEvent, MatchEndEvent, RoomClosedEvent } from '../types/room.js' -const GAME_CONFIG = { - TICK_RATE: 60, // Increased from 30 for lower latency - MATCH_DURATION: 120, -} as const +// Must match GAME_CONFIG in shared/src/types.ts +const TICK_RATE = 60 +const MATCH_DURATION = 120 // Fixed timestep physics configuration -const FIXED_TIMESTEP_MS = 1000 / 60 // 16.666ms - deterministic physics step +const FIXED_TIMESTEP_MS = 1000 / TICK_RATE // 16.666ms - deterministic physics step const FIXED_TIMESTEP_S = FIXED_TIMESTEP_MS / 1000 // 0.01666s const MAX_PHYSICS_STEPS = 24 // Increased to handle high CPU contention during parallel tests (8 workers = 16 browser contexts) @@ -47,8 +46,11 @@ export class MatchRoom extends Room { const gameState = new GameState() this.setState(gameState) + // Sync state to clients at tick rate (default is 50ms/20Hz — too slow for smooth play) + this.patchRate = 1000 / TICK_RATE + // Start game loop at 60 Hz - this.setSimulationInterval((deltaTime) => this.update(deltaTime), 1000 / GAME_CONFIG.TICK_RATE) + this.setSimulationInterval((deltaTime) => this.update(deltaTime), 1000 / TICK_RATE) // Handle player inputs (multiple inputs per message) this.onMessage('inputs', (client, message) => { @@ -161,7 +163,7 @@ export class MatchRoom extends Room { // Reset physics accumulator for clean deterministic start this.physicsAccumulator = 0 - const startEvent: MatchStartEvent = { duration: GAME_CONFIG.MATCH_DURATION } + const startEvent: MatchStartEvent = { duration: MATCH_DURATION } this.broadcast('match_start', startEvent) } diff --git a/server/src/schema/GameState.ts b/server/src/schema/GameState.ts index c5b5da5..5573fb7 100644 --- a/server/src/schema/GameState.ts +++ b/server/src/schema/GameState.ts @@ -74,6 +74,8 @@ export class GameState extends Schema { matchDuration: GAME_CONFIG.MATCH_DURATION, // Hold last movement for ~100ms at 60Hz to mask packet loss holdLastInputFrames: 6, + // Instant response — network latency provides natural inertia feel + playerAcceleration: 1, }) // Register callbacks diff --git a/shared/src/engine/GameEngine.ts b/shared/src/engine/GameEngine.ts index ec84e4e..c206ad6 100644 --- a/shared/src/engine/GameEngine.ts +++ b/shared/src/engine/GameEngine.ts @@ -21,6 +21,8 @@ export interface GameEngineConfig { // Optional: hold last movement input for a few physics frames to mask packet loss // Set to 0 to disable (default) holdLastInputFrames?: number + // Override player acceleration (0-1). Default uses GAME_CONFIG.PLAYER_ACCELERATION + playerAcceleration?: number } export class GameEngine { @@ -63,6 +65,7 @@ export class GameEngine { fieldWidth: GAME_CONFIG.FIELD_WIDTH, fieldHeight: GAME_CONFIG.FIELD_HEIGHT, playerSpeed: GAME_CONFIG.PLAYER_SPEED, + playerAcceleration: config.playerAcceleration ?? GAME_CONFIG.PLAYER_ACCELERATION, ballRadius: GAME_CONFIG.BALL_RADIUS, ballFriction: GAME_CONFIG.BALL_FRICTION, shootSpeed: GAME_CONFIG.SHOOT_SPEED, diff --git a/shared/src/engine/PhysicsEngine.ts b/shared/src/engine/PhysicsEngine.ts index 5b6706a..48d6588 100644 --- a/shared/src/engine/PhysicsEngine.ts +++ b/shared/src/engine/PhysicsEngine.ts @@ -110,10 +110,15 @@ export class PhysicsEngine { const targetVelocityX = input.movement.x * this.config.playerSpeed const targetVelocityY = input.movement.y * this.config.playerSpeed - // Inertia: smooth acceleration/deceleration (lower = more momentum, higher = faster response) - const ACCELERATION_FACTOR = 0.15 - player.velocityX += (targetVelocityX - player.velocityX) * ACCELERATION_FACTOR - player.velocityY += (targetVelocityY - player.velocityY) * ACCELERATION_FACTOR + // Acceleration: 1 = instant, lower = more inertia/momentum + const accel = this.config.playerAcceleration + if (accel >= 1) { + player.velocityX = targetVelocityX + player.velocityY = targetVelocityY + } else { + player.velocityX += (targetVelocityX - player.velocityX) * accel + player.velocityY += (targetVelocityY - player.velocityY) * accel + } // Update position player.x += player.velocityX * dt diff --git a/shared/src/engine/types.ts b/shared/src/engine/types.ts index ee03159..a0b86c9 100644 --- a/shared/src/engine/types.ts +++ b/shared/src/engine/types.ts @@ -68,6 +68,7 @@ export interface PhysicsConfig { fieldWidth: number fieldHeight: number playerSpeed: number + playerAcceleration: number // 0-1: lower = more inertia, 1 = instant ballRadius: number ballFriction: number shootSpeed: number diff --git a/shared/src/types.ts b/shared/src/types.ts index 17130e4..2c745cf 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -68,6 +68,7 @@ export const GAME_CONFIG = { // Player physics PLAYER_SPEED: Math.round(FIELD_HEIGHT * 0.35), + PLAYER_ACCELERATION: 0.15, // 0-1: lower = more inertia, 1 = instant // Ball physics BALL_FRICTION: 0.98, @@ -78,5 +79,6 @@ export const GAME_CONFIG = { GOAL_Y_MAX: Math.round(FIELD_HEIGHT / 2 + (FIELD_HEIGHT * GOAL_HEIGHT_RATIO) / 2), // Game timing + TICK_RATE: 60, MATCH_DURATION: 120, } as const diff --git a/tests/network-smoothness.spec.ts b/tests/network-smoothness.spec.ts new file mode 100644 index 0000000..2555fcf --- /dev/null +++ b/tests/network-smoothness.spec.ts @@ -0,0 +1,152 @@ +import { test, expect } from './fixtures' +import { setupMultiClientTest } from './helpers/room-utils' + +test.describe('Network Smoothness', () => { + test('Remote player movement is visually smooth', async ({ browser }, testInfo) => { + const context1 = await browser.newContext() + const context2 = await browser.newContext() + const page1 = await context1.newPage() + const page2 = await context2.newPage() + + await setupMultiClientTest([page1, page2], '/', testInfo.workerIndex) + + // Wait for match to reach 'playing' phase on both clients + await Promise.all([ + page1.waitForFunction(() => { + const state = (window as any).__gameControls?.scene?.networkManager?.getState() + return state?.phase === 'playing' + }, { timeout: 30000 }), + page2.waitForFunction(() => { + const state = (window as any).__gameControls?.scene?.networkManager?.getState() + return state?.phase === 'playing' + }, { timeout: 30000 }), + ]) + + // Get Player 1's ID (from Client A) + const p1Id = await page1.evaluate( + () => (window as any).__gameControls.scene.myPlayerId as string + ) + + // Ensure P1 sprite exists on Client B + await page2.waitForFunction( + (id: string) => (window as any).__gameControls.scene.players.has(id), + p1Id, + { timeout: 10000 } + ) + + // Client A: move player rightward for 2 seconds via direct test API + const movePromise = page1.evaluate(() => { + return (window as any).__gameControls.test.movePlayerDirect(1, 0, 2000) + }) + + // Client B: sample remote player sprite positions over 2.5 seconds + const metrics = await page2.evaluate( + (targetId: string) => { + return new Promise<{ + avgDelta: number + deltaVariance: number + maxJump: number + stallCount: number + jumpCount: number + stallRatio: number + jumpRatio: number + totalFrames: number + samples: number + }>((resolve) => { + const positions: { x: number; y: number; t: number }[] = [] + const startTime = performance.now() + const duration = 1800 // Shorter than 2s movement to avoid measuring the stop transition + + function sample() { + const now = performance.now() + if (now - startTime > duration) { + // Compute metrics + const deltas: number[] = [] + let maxJump = 0 + + for (let i = 1; i < positions.length; i++) { + const dx = positions[i].x - positions[i - 1].x + const dy = positions[i].y - positions[i - 1].y + const delta = Math.sqrt(dx * dx + dy * dy) + deltas.push(delta) + if (delta > maxJump) maxJump = delta + } + + const totalFrames = deltas.length + if (totalFrames === 0) { + resolve({ + avgDelta: 0, deltaVariance: 0, maxJump: 0, + stallCount: 0, jumpCount: 0, stallRatio: 1, jumpRatio: 0, + totalFrames: 0, samples: positions.length, + }) + return + } + + const avgDelta = deltas.reduce((s, d) => s + d, 0) / totalFrames + const deltaVariance = + deltas.reduce((s, d) => s + (d - avgDelta) ** 2, 0) / totalFrames + + const stallThreshold = 0.1 + const stallCount = avgDelta > 1 + ? deltas.filter((d) => d < stallThreshold).length + : 0 + const jumpThreshold = avgDelta * 2 + const jumpCount = deltas.filter((d) => d > jumpThreshold).length + + resolve({ + avgDelta: Math.round(avgDelta * 100) / 100, + deltaVariance: Math.round(deltaVariance * 100) / 100, + maxJump: Math.round(maxJump * 100) / 100, + stallCount, + jumpCount, + stallRatio: Math.round((stallCount / totalFrames) * 1000) / 1000, + jumpRatio: Math.round((jumpCount / totalFrames) * 1000) / 1000, + totalFrames, + samples: positions.length, + }) + return + } + + const sprite = (window as any).__gameControls?.scene?.players?.get(targetId) + if (sprite) { + positions.push({ x: sprite.x, y: sprite.y, t: now }) + } + requestAnimationFrame(sample) + } + + requestAnimationFrame(sample) + }) + }, + p1Id + ) + + // Wait for movement to complete + await movePromise + + // Log raw metrics for tuning + console.log('=== Network Smoothness Metrics (Client B observing Client A) ===') + console.log(` Total frames sampled: ${metrics.totalFrames}`) + console.log(` Avg delta (px/frame): ${metrics.avgDelta}`) + console.log(` Delta variance: ${metrics.deltaVariance}`) + console.log(` Max jump (px): ${metrics.maxJump}`) + console.log(` Stall count: ${metrics.stallCount}`) + console.log(` Jump count: ${metrics.jumpCount}`) + console.log(` Stall ratio: ${metrics.stallRatio}`) + console.log(` Jump ratio: ${metrics.jumpRatio}`) + console.log('================================================================') + + // Verify we got some data (basic sanity) + expect(metrics.totalFrames).toBeGreaterThan(0) + + // Quality assertions only when we have enough frames for meaningful metrics. + // CI runners (~2-5 fps) produce too few frames; skip quality checks there. + if (metrics.totalFrames >= 30) { + expect(metrics.maxJump).toBeLessThan(50) + expect(metrics.stallRatio).toBeLessThan(0.05) + expect(metrics.jumpRatio).toBeLessThan(0.15) + } + + await context1.close() + await context2.close() + }) +})