Open
Conversation
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.
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%.
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())
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
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)
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
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.
… 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.
…smoother networking" This reverts commit c7756c9.
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).
…railing 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.
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.
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.
…ad 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
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
…rmula - 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
- 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
- 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
- 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 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
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.
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.
Players who lose possession via tackle are stunned for 1s: 50% movement speed, cannot recapture ball or tackle, with smooth shrink + desaturate visual effect. Applies to both human and AI players; stun clears on goal reset.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes
'stunned'toPlayerStatestunnedUntilfield andSTUN_DURATION_MS/STUN_SPEED_FACTORconstantsRemotePlayer.statetype for'stunned'Test plan
npm run test:e2e(14/14 passing)