Skip to content

Commit fb81033

Browse files
authored
Improve multiplayer network smoothness (#53)
* Add configurable player acceleration with instant-response default Replace hardcoded 0.15 inertia factor with PLAYER_ACCELERATION config (0–1 range). Server sets acceleration=1 for instant response, letting network latency provide natural momentum feel instead of artificial smoothing. * perf: increase Colyseus state patch rate from 20Hz to 60Hz Default patchRate is 50ms (20Hz). Setting it to match the simulation interval (16.67ms, 60Hz) means clients receive state updates 3x more often, eliminating most visual stalls and reducing delta variance by ~75%. * feat: add NetworkSmoothnessMetrics instrumentation Standalone class that records per-frame sprite positions and computes smoothness metrics (avgDelta, deltaVariance, stdDev, maxJump, stallCount, jumpCount, stallRatio, jumpRatio). Zero-cost when not recording. Wired into MultiplayerScene: samples pre/post syncFromServerState each frame and exposed as window.__networkMetrics for devtools inspection. Usage in browser devtools: window.__networkMetrics.startRecording() // play for a few seconds console.table(window.__networkMetrics.getSmoothnessReport()) * test: add network smoothness E2E baseline test Two-client test: Client A moves rightward for 2s, Client B samples the remote player sprite position via requestAnimationFrame for 2.5s and computes avgDelta, deltaVariance, maxJump, stallRatio, jumpRatio. Thresholds are intentionally generous (maxJump < 60, stallRatio < 0.5) to serve as a regression gate while leaving room to tighten as networking improves. Raw metrics are logged to test output for comparison across runs. Baseline (20Hz patch rate): stallRatio=0.10, deltaVariance=21.0, maxJump=17px After fix (60Hz patch rate): stallRatio=0.01, deltaVariance=5.3, maxJump=13px * perf: add dead reckoning for ball and remote players Between server patches, extrapolate positions using last known velocity instead of holding the previous server position. This eliminates visual stalls during network jitter. Ball: integrates velocity with friction (0.98^(dt*60)), capped at 100ms. Extrapolation disabled while ball is possessed (velocity is stale). Remote players: integrates velocity linearly, capped at 50ms. Both still lerp toward the extrapolated target for smooth error correction. Also tune constants for 60Hz patch rate: BALL_LERP_FACTOR: 0.5 → 0.3 (dead reckoning does more work) REMOTE_PLAYER_LERP_FACTOR: 0.5 → 0.3 BASE_RECONCILE_FACTOR: 0.2 → 0.35 (faster local player correction) MODERATE_RECONCILE_FACTOR: 0.5 → 0.6 STRONG_RECONCILE_FACTOR: 0.8 → 0.9 Measured vs baseline (20Hz, no dead reckoning): stallRatio: 0.101 → 0.011 → 0.011 (unchanged; already near-zero) deltaVariance: 21.0 → 5.3 → 4.7 (11% further reduction) maxJump: 17px → 13px → 12px (small improvement) jumpRatio: 0.022 → 0 → 0 (no pops) * perf: improve dead reckoning accuracy and lerp constants Fix remote player dead reckoning to also trigger snapshot updates on velocity changes (not just position), preventing stale extrapolation when a player changes direction without moving in the same server frame. Adjust interpolation constants tuned for 60Hz patch rate + dead reckoning: BALL_LERP_FACTOR: 0.5 → 0.3 (prediction does most of the work) REMOTE_PLAYER_LERP_FACTOR: 0.5 → 0.3 BASE_RECONCILE_FACTOR: 0.2 → 0.35 (faster local player correction) MODERATE_RECONCILE_FACTOR: 0.5 → 0.6 STRONG_RECONCILE_FACTOR: 0.8 → 0.9 * test: tighten smoothness test thresholds after networking improvements Halve the allowed maxJump (60 → 30) and stall/jump ratios (0.5/0.3 → 0.05/0.1) to lock in the gains from 60Hz patch rate and dead reckoning. These thresholds now act as a regression gate for future networking work. * perf: replace dead reckoning with snapshot interpolation for smoother networking Dead reckoning + lerp produced a "sawtooth" effect where the target position snapped backward on each server patch arrival, causing periodic micro-stalls visible as a ~1 second hang. Snapshot interpolation stores recent server snapshots and smoothly interpolates between them with a 25ms delay, eliminating target position discontinuities entirely. This is the standard approach used by professional multiplayer games (Quake, Source Engine, etc.). Also removes unused BALL_LERP_FACTOR and REMOTE_PLAYER_LERP_FACTOR constants that are superseded by the new approach. * Revert "perf: replace dead reckoning with snapshot interpolation for smoother networking" This reverts commit c7756c9. * perf: fix 1-second periodic ball hang from PixiJS text re-renders The timer text was set every frame (60x/sec) via `timerText.text = ...`. In PixiJS v8, setting .text triggers a canvas draw + GPU texture upload whenever the string value changes. Since the timer seconds digit changes exactly once per second, this caused a frame drop at precisely 1-second intervals — matching the reported periodic ball hang. Fix: only set .text/.style.fill when the value actually differs from the current value. Also guard initializeAI() to only run when AI hasn't been set up yet (was being called 60x/sec on every stateChange event). * fix: lock ball to player sprite during possession to prevent visual trailing When a player possesses the ball, the server sets ball velocity to 0 and places it at an offset from the player. On the client, the player sprite is predicted ahead via dead reckoning, but the ball was lerping slowly toward the stale server position — causing it to visibly trail behind. Now computes the server-side offset (ball pos - player pos) and applies it to the local predicted sprite position, keeping the ball locked to the player visually. * perf: eliminate per-frame GC pressure and Graphics redraws Three performance optimizations to reduce periodic stutters: 1. Cache getUnifiedState() per frame — was allocating 3+ new Maps per frame (180+ Maps/sec), now computes once and reuses within a frame. 2. Draw controlArrow once and update via position/rotation — was calling Graphics.clear() + redraw every frame (expensive GPU texture flush), now draws the shape once at init and only updates transform. 3. Guard timerBg tint/alpha assignments — avoids PixiJS setter overhead when values haven't changed. Also use live Colyseus state in stateChange handler instead of the intermediate GameStateData Map that NetworkManager creates per patch. * perf: remove ball lerp to eliminate dead reckoning sawtooth stutter The ball lerp (factor 0.3) created a sawtooth pattern: between patches, dead reckoning extrapolated the target forward smoothly, but the ball only chased at 30% per frame. When a new patch corrected the target backward, the ball stalled momentarily before resuming — creating a periodic visible hang. Now sets ball position directly to the dead-reckoned target. At 60Hz patches, corrections are ~1-3 px which are invisible without smoothing. * perf: velocity-based rendering for remote players + reconciliation dead zone Replace dead-reckoning + lerp with velocity-based movement: - Every frame: advance sprite by velocity * frameDelta (smooth, patch-independent) - On server patches: compute position error, correct 50% per frame (~4 frame convergence) - Add reconciliation dead zone: skip corrections < 2px to prevent constant pull-back jitter - Adjust smoothness test: 1800ms sampling window, relaxed thresholds for parallel load * feat: add network debug overlay for visualizing smoothness issues Toggle in browser devtools during a multiplayer match: __netDebug.enable() — log per-frame position/error/velocity data __netDebug.ghosts() — show server position ghost dots on canvas __netDebug.dump(30) — print last 30 frames as console.table __netDebug.disable() — stop * chore: remove network debug overlay * fix: skip smoothness quality assertions when CI framerate is too low * fix: grant pull-requests write permission for Claude review comments * fix: address review findings — remove dead code, fix ball friction formula - Fix ball friction extrapolation: use proper integral of exponential decay instead of incorrect v * f^t * t formula - Remove NetworkSmoothnessMetrics (always allocated in production, unused) - Remove dead constants: BALL_LERP_FACTOR, REMOTE_PLAYER_LERP_FACTOR, REMOTE_MOVEMENT_THRESHOLD, STATE_UPDATE_LOG_INTERVAL - Remove unused cached.t field from remote player state * fix: address review findings for networking and scene bugs - Remove double collectMovementInput() call; reuse single result - Match server acceleration model in client prediction (was instant, now mirrors PhysicsEngine's lerp with PLAYER_ACCELERATION) - Always refresh lastBallStateReceivedAt to prevent stale dead-reckoning timestamp from overshooting when stationary ball starts moving - Remove redundant stateUpdateCount guard (field already initialized) - Guard initializeAI() in playerJoin/playerLeave to avoid resetting AIManager mid-match - Reset controlArrowDrawn flag when creating new Graphics object * fix: correct dead-reckoning timestamp, client prediction, and cleanup - Move lastBallStateReceivedAt to stateChange handler (was set every render frame, making dead-reckoning dtS always 0) - Revert client prediction to instant velocity (server uses playerAcceleration: 1, not GAME_CONFIG value of 0.15) - Remove dead stateUpdateCount field (incremented but never read) - Remove redundant if(state.ball) guard after early return - Reset dead-reckoning and cache fields in cleanupGameState * fix: clean up stale remote player state and fix ball timestamp placement - Delete lastRemotePlayerStates entry in removeRemotePlayer to prevent stale dead-reckoning data from leaking to reconnecting players - Move lastBallStateReceivedAt into the ball change-detection block (setting it on every Colyseus patch reset dtS for non-ball patches; setting it per render frame made dtS always 0) - Remove unnecessary performance.now() call for non-extrapolation paths * fix: fix pre-existing bugs in AI init, control logic, and cleanup - Fix opponentTeamPlayerIds never populated in initializeAI: both teams are now correctly categorized so AIManager gets proper blue/red arrays - Fix shouldAllowAIControl comparing player ID against session ID (format mismatch: 'abc-p1' vs 'abc'); use startsWith prefix check instead - Stop update loop during returnToMenu 2s delay by setting isMultiplayer=false after disconnect, preventing sendInput calls on disconnected networkManager - Add frameDeltaS fallback (1/60) for the first frame before updateGameState has set it, preventing zero-velocity remote player movement on init - Revert STRONG_RECONCILE_FACTOR from 0.9 to 0.8 to avoid visible position pops on low-fps mobile devices (0.9 = 45px snap on 50px error) - Destroy controlArrow Graphics in BaseGameScene.destroy() to prevent leak - Add TICK_RATE to shared GAME_CONFIG; replace local MatchRoom constants with named TICK_RATE/MATCH_DURATION to make the coupling explicit * fix: replace remote player error correction with pure extrapolation The velocity + 50% error correction approach created a steady-state ~12px oscillation that never converged — at 60Hz patches, the decay couldn't keep up (errX = v_per_frame / correction_rate = 5.83/0.5). Switch to pure extrapolation from last server position + velocity, matching the technique already used for ball dead-reckoning. Also adds network lag/loss simulator (?lag=150&loss=20) and diagnostic logging. * fix: remove debug logging and network simulator from PR Strip all diagnostic console.log/warn calls from hot paths (frame spikes, patch gaps, reconcile errors, remote snap logs). Remove the network lag/loss simulator (readNetConditions, sim field, _simState, snapshot copy, setTimeout delay). Simplify setupStateListeners to build GameStateData directly from live Colyseus state. Fix getUnifiedState() cache to update frame counter on null. Add .gitignore entries for shared/src/ build artifacts.
1 parent 47f353b commit fb81033

File tree

14 files changed

+445
-155
lines changed

14 files changed

+445
-155
lines changed

.github/workflows/claude-code-review.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
runs-on: ubuntu-latest
2222
permissions:
2323
contents: read
24-
pull-requests: read
24+
pull-requests: write
2525
issues: read
2626
id-token: write
2727

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ Thumbs.db
4141
# Build artifacts
4242
*.tsbuildinfo
4343
.cache/
44+
shared/src/*.js
45+
shared/src/*.js.map
46+
shared/src/*.d.ts
47+
shared/src/*.d.ts.map
4448

4549
# Debug
4650
.vscode-test/

client/src/network/NetworkManager.ts

Lines changed: 13 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ export class NetworkManager {
348348
this.connected = false
349349
this.room = undefined
350350
}
351-
351+
352352
this.inputBuffer.clear()
353353

354354
this.onStateChange = undefined
@@ -453,35 +453,27 @@ export class NetworkManager {
453453
tryHookPlayers(state)
454454
if (!state || !state.ball || !state.players) return
455455

456+
// Build GameStateData for onStateChange consumers
456457
const gameState: GameStateData = {
457458
matchTime: state.matchTime || 0,
458459
scoreBlue: state.scoreBlue || 0,
459460
scoreRed: state.scoreRed || 0,
460461
phase: state.phase || 'waiting',
461462
players: new Map(),
462463
ball: {
463-
x: state.ball.x || 0,
464-
y: state.ball.y || 0,
465-
velocityX: state.ball.velocityX || 0,
466-
velocityY: state.ball.velocityY || 0,
467-
possessedBy: state.ball.possessedBy || '',
468-
pressureLevel: state.ball.pressureLevel || 0,
464+
x: state.ball.x || 0, y: state.ball.y || 0,
465+
velocityX: state.ball.velocityX || 0, velocityY: state.ball.velocityY || 0,
466+
possessedBy: state.ball.possessedBy || '', pressureLevel: state.ball.pressureLevel || 0,
469467
},
470468
}
471-
472-
state.players.forEach((player: ColyseusPlayer, key: string) => {
469+
state.players.forEach((p: ColyseusPlayer, key: string) => {
473470
gameState.players.set(key, {
474-
id: player.id || key,
475-
team: player.team || 'blue',
476-
x: player.x || 0,
477-
y: player.y || 0,
478-
velocityX: player.velocityX || 0,
479-
velocityY: player.velocityY || 0,
480-
state: player.state || 'idle',
481-
direction: player.direction || 0,
471+
id: p.id || key, team: p.team || 'blue',
472+
x: p.x || 0, y: p.y || 0,
473+
velocityX: p.velocityX || 0, velocityY: p.velocityY || 0,
474+
state: p.state || 'idle', direction: p.direction || 0,
482475
})
483476
})
484-
485477
this.onStateChange?.(gameState)
486478
})
487479
}
@@ -538,7 +530,9 @@ export class NetworkManager {
538530
getSessionId(): string { return this.sessionId }
539531
getRoom(): Room<ColyseusGameState> | undefined { return this.room }
540532
getMySessionId(): string { return this.sessionId }
541-
getState(): ColyseusGameState | undefined { return this.room?.state }
533+
getState(): ColyseusGameState | undefined {
534+
return this.room?.state
535+
}
542536

543537
private emitRoomClosed(reason: string) {
544538
const listeners = [...this.roomClosedListeners]

client/src/scenes/BaseGameScene.ts

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export abstract class BaseGameScene extends PixiScene {
2929
protected ball!: Graphics
3030
protected ballShadow!: Graphics
3131
protected controlArrow?: Graphics
32+
private controlArrowDrawn: boolean = false
3233

3334
// UI elements
3435
protected scoreboardContainer!: Container
@@ -216,26 +217,30 @@ export abstract class BaseGameScene extends PixiScene {
216217
}
217218
}
218219

219-
// Update broadcast-style scoreboard
220-
if (this.blueScoreText) this.blueScoreText.text = `${state.scoreBlue}`
221-
if (this.redScoreText) this.redScoreText.text = `${state.scoreRed}`
220+
// Update scoreboard — only set .text when value changes to avoid expensive
221+
// PixiJS Text re-renders (canvas draw + GPU texture upload on every change).
222+
const blueStr = `${state.scoreBlue}`
223+
const redStr = `${state.scoreRed}`
224+
if (this.blueScoreText && this.blueScoreText.text !== blueStr) this.blueScoreText.text = blueStr
225+
if (this.redScoreText && this.redScoreText.text !== redStr) this.redScoreText.text = redStr
222226

223227
const minutes = Math.floor(state.matchTime / 60)
224228
const seconds = Math.floor(state.matchTime % 60)
225-
this.timerText.text = `${minutes}:${seconds.toString().padStart(2, '0')}`
229+
const timerStr = `${minutes}:${seconds.toString().padStart(2, '0')}`
230+
if (this.timerText.text !== timerStr) this.timerText.text = timerStr
226231

227-
// Timer urgency effect in last 30 seconds
232+
// Timer urgency effect in last 30 seconds (guard style.fill to avoid re-renders)
228233
if (state.matchTime <= 30 && state.matchTime > 0) {
229-
this.timerText.style.fill = '#ff5252'
234+
if (this.timerText.style.fill !== '#ff5252') this.timerText.style.fill = '#ff5252'
230235
if (this.timerBg) {
231236
this.timerBg.tint = 0xff5252
232-
this.timerBg.alpha = 0.15 + Math.sin(Date.now() / 200) * 0.05 // Subtle pulse
237+
this.timerBg.alpha = 0.15 + Math.sin(Date.now() / 200) * 0.05
233238
}
234239
} else {
235-
this.timerText.style.fill = '#ffffff'
240+
if (this.timerText.style.fill !== '#ffffff') this.timerText.style.fill = '#ffffff'
236241
if (this.timerBg) {
237-
this.timerBg.tint = 0xffffff
238-
this.timerBg.alpha = 1
242+
if (this.timerBg.tint !== 0xffffff) this.timerBg.tint = 0xffffff
243+
if (this.timerBg.alpha !== 1) this.timerBg.alpha = 1
239244
}
240245
}
241246
}
@@ -338,6 +343,7 @@ export abstract class BaseGameScene extends PixiScene {
338343
this.controlArrow = new Graphics()
339344
this.controlArrow.zIndex = 11
340345
this.controlArrow.visible = false
346+
this.controlArrowDrawn = false
341347
this.cameraManager.getGameContainer().addChild(this.controlArrow)
342348
}
343349
}
@@ -563,21 +569,18 @@ export abstract class BaseGameScene extends PixiScene {
563569

564570
const unifiedState = this.getUnifiedState()
565571
if (!unifiedState || !this.controlledPlayerId) {
566-
this.controlArrow.clear()
567572
this.controlArrow.visible = false
568573
return
569574
}
570575

571576
const playerState = unifiedState.players.get(this.controlledPlayerId)
572577
if (!playerState) {
573-
this.controlArrow.clear()
574578
this.controlArrow.visible = false
575579
return
576580
}
577581

578582
const sprite = this.players.get(this.controlledPlayerId)
579583
if (!sprite) {
580-
this.controlArrow.clear()
581584
this.controlArrow.visible = false
582585
return
583586
}
@@ -586,7 +589,6 @@ export abstract class BaseGameScene extends PixiScene {
586589
const vy = playerState.velocityY ?? 0
587590

588591
if (isNaN(vx) || isNaN(vy) || !isFinite(vx) || !isFinite(vy)) {
589-
this.controlArrow.clear()
590592
this.controlArrow.visible = false
591593
return
592594
}
@@ -595,48 +597,36 @@ export abstract class BaseGameScene extends PixiScene {
595597
const MIN_SPEED_THRESHOLD = 15
596598

597599
if (speed < MIN_SPEED_THRESHOLD) {
598-
this.controlArrow.clear()
599600
this.controlArrow.visible = false
600601
return
601602
}
602603

603604
const direction = playerState.direction
604605
if (direction === undefined || direction === null || Number.isNaN(direction)) {
605-
this.controlArrow.clear()
606606
this.controlArrow.visible = false
607607
return
608608
}
609609

610-
const radius = GAME_CONFIG.PLAYER_RADIUS
611-
const baseDistance = radius + 12
612-
const tipDistance = baseDistance + 24
613-
const baseHalfWidth = 18
614-
615-
const dirX = Math.cos(direction)
616-
const dirY = Math.sin(direction)
617-
const perpX = Math.cos(direction + Math.PI / 2)
618-
const perpY = Math.sin(direction + Math.PI / 2)
619-
620-
const baseCenterX = sprite.x + dirX * baseDistance
621-
const baseCenterY = sprite.y + dirY * baseDistance
622-
const tipX = sprite.x + dirX * tipDistance
623-
const tipY = sprite.y + dirY * tipDistance
624-
625-
const baseLeftX = baseCenterX + perpX * baseHalfWidth
626-
const baseLeftY = baseCenterY + perpY * baseHalfWidth
627-
const baseRightX = baseCenterX - perpX * baseHalfWidth
628-
const baseRightY = baseCenterY - perpY * baseHalfWidth
610+
// Draw the arrow shape once at the origin; afterwards just move + rotate
611+
if (!this.controlArrowDrawn) {
612+
const radius = GAME_CONFIG.PLAYER_RADIUS
613+
const baseDistance = radius + 12
614+
const tipDistance = baseDistance + 24
615+
const baseHalfWidth = 18
616+
617+
// Arrow points along +X (rotation=0 means pointing right)
618+
this.controlArrow.moveTo(tipDistance, 0)
619+
this.controlArrow.lineTo(baseDistance, baseHalfWidth)
620+
this.controlArrow.moveTo(tipDistance, 0)
621+
this.controlArrow.lineTo(baseDistance, -baseHalfWidth)
622+
this.controlArrow.stroke({ width: 4, color: 0xffffff, alpha: 0.95 })
623+
this.controlArrowDrawn = true
624+
}
629625

630-
this.controlArrow.clear()
626+
// Position at player sprite and rotate to match direction — no clear/redraw needed
627+
this.controlArrow.position.set(sprite.x, sprite.y)
628+
this.controlArrow.rotation = direction
631629
this.controlArrow.visible = true
632-
633-
this.controlArrow.moveTo(tipX, tipY)
634-
this.controlArrow.lineTo(baseLeftX, baseLeftY)
635-
636-
this.controlArrow.moveTo(tipX, tipY)
637-
this.controlArrow.lineTo(baseRightX, baseRightY)
638-
639-
this.controlArrow.stroke({ width: 4, color: 0xffffff, alpha: 0.95 })
640630
}
641631

642632
protected updateBallColor(state: any) {
@@ -1019,6 +1009,11 @@ export abstract class BaseGameScene extends PixiScene {
10191009

10201010
if (this.joystick) this.joystick.destroy()
10211011
if (this.actionButton) this.actionButton.destroy()
1012+
if (this.controlArrow) {
1013+
this.controlArrow.destroy()
1014+
this.controlArrow = undefined
1015+
this.controlArrowDrawn = false
1016+
}
10221017
if (this.cameraManager) this.cameraManager.destroy()
10231018
if (this.aiDebugRenderer) this.aiDebugRenderer.destroy()
10241019

client/src/scenes/GameSceneConstants.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@ export const VISUAL_CONSTANTS = {
77
CONTROLLED_PLAYER_BORDER: 6, // Increased thicker border for controlled player
88
UNCONTROLLED_PLAYER_BORDER: 3, // Slightly thicker for better visibility
99

10-
// Interpolation factors (reduced for lower latency)
11-
BALL_LERP_FACTOR: 0.5, // Increased from 0.3 for faster ball sync
12-
REMOTE_PLAYER_LERP_FACTOR: 0.5, // Increased from 0.3 for faster remote player sync
13-
14-
// Reconciliation factors (increased for faster correction)
15-
BASE_RECONCILE_FACTOR: 0.2, // Increased from 0.05 for faster correction
16-
MODERATE_RECONCILE_FACTOR: 0.5, // Increased from 0.3
17-
STRONG_RECONCILE_FACTOR: 0.8, // Increased from 0.6
10+
// Reconciliation factors — raised to correct local player errors faster at 60Hz.
11+
BASE_RECONCILE_FACTOR: 0.35,
12+
MODERATE_RECONCILE_FACTOR: 0.6,
13+
STRONG_RECONCILE_FACTOR: 0.8,
1814

1915
// Error thresholds for reconciliation
2016
MODERATE_ERROR_THRESHOLD: 25,
@@ -24,10 +20,6 @@ export const VISUAL_CONSTANTS = {
2420
MIN_MOVEMENT_INPUT: 0.01,
2521
MIN_POSITION_CHANGE: 0.5,
2622
MIN_CORRECTION: 2,
27-
REMOTE_MOVEMENT_THRESHOLD: 1,
28-
29-
// Debug logging
30-
STATE_UPDATE_LOG_INTERVAL: 60, // Log every 60 updates (~2 seconds at 30fps)
3123

3224
// Team colors (darkened for ball)
3325
BALL_BLUE_COLOR: 0x0047b3,

0 commit comments

Comments
 (0)