Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4246081
Add configurable player acceleration with instant-response default
tim4724 Feb 23, 2026
682d635
perf: increase Colyseus state patch rate from 20Hz to 60Hz
tim4724 Feb 24, 2026
f14ff38
feat: add NetworkSmoothnessMetrics instrumentation
tim4724 Feb 24, 2026
7e5cbc1
test: add network smoothness E2E baseline test
tim4724 Feb 24, 2026
42edff5
perf: add dead reckoning for ball and remote players
tim4724 Feb 24, 2026
6a749ea
perf: improve dead reckoning accuracy and lerp constants
tim4724 Feb 24, 2026
ce22627
test: tighten smoothness test thresholds after networking improvements
tim4724 Feb 24, 2026
dc2a53f
perf: replace dead reckoning with snapshot interpolation for smoother…
tim4724 Feb 24, 2026
631940b
Revert "perf: replace dead reckoning with snapshot interpolation for …
tim4724 Feb 24, 2026
18561f1
perf: fix 1-second periodic ball hang from PixiJS text re-renders
tim4724 Feb 24, 2026
bfd8b47
fix: lock ball to player sprite during possession to prevent visual t…
tim4724 Feb 24, 2026
6219d5d
perf: eliminate per-frame GC pressure and Graphics redraws
tim4724 Feb 24, 2026
45525fb
perf: remove ball lerp to eliminate dead reckoning sawtooth stutter
tim4724 Feb 24, 2026
891277d
perf: velocity-based rendering for remote players + reconciliation de…
tim4724 Feb 24, 2026
bc9c5a1
feat: add network debug overlay for visualizing smoothness issues
tim4724 Feb 24, 2026
65ecb3c
chore: remove network debug overlay
tim4724 Feb 25, 2026
e3984f0
fix: skip smoothness quality assertions when CI framerate is too low
tim4724 Feb 25, 2026
f2fead0
fix: grant pull-requests write permission for Claude review comments
tim4724 Feb 25, 2026
f8a5443
fix: address review findings — remove dead code, fix ball friction fo…
tim4724 Feb 25, 2026
3ed6383
fix: address review findings for networking and scene bugs
tim4724 Feb 25, 2026
445fa36
fix: correct dead-reckoning timestamp, client prediction, and cleanup
tim4724 Feb 25, 2026
01cfcea
fix: clean up stale remote player state and fix ball timestamp placement
tim4724 Feb 25, 2026
82edf81
fix: fix pre-existing bugs in AI init, control logic, and cleanup
tim4724 Feb 25, 2026
764353c
fix: replace remote player error correction with pure extrapolation
tim4724 Feb 28, 2026
49c02cb
fix: remove debug logging and network simulator from PR
tim4724 Feb 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
pull-requests: write
issues: read
id-token: write

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
32 changes: 13 additions & 19 deletions client/src/network/NetworkManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ export class NetworkManager {
this.connected = false
this.room = undefined
}

this.inputBuffer.clear()

this.onStateChange = undefined
Expand Down Expand Up @@ -453,35 +453,27 @@ 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,
scoreRed: state.scoreRed || 0,
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)
})
}
Expand Down Expand Up @@ -538,7 +530,9 @@ export class NetworkManager {
getSessionId(): string { return this.sessionId }
getRoom(): Room<ColyseusGameState> | 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]
Expand Down
83 changes: 39 additions & 44 deletions client/src/scenes/BaseGameScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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()

Expand Down
16 changes: 4 additions & 12 deletions client/src/scenes/GameSceneConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading