Skip to content

feat: add stun mechanic after losing ball#54

Open
tim4724 wants to merge 26 commits intomainfrom
feat/stun-mechanic
Open

feat: add stun mechanic after losing ball#54
tim4724 wants to merge 26 commits intomainfrom
feat/stun-mechanic

Conversation

@tim4724
Copy link
Owner

@tim4724 tim4724 commented Feb 28, 2026

Summary

  • Players who lose possession via tackle are stunned for 1 second: 50% movement speed, cannot recapture ball or tackle opponents
  • Smooth visual effect: player circle shrinks to 85% and desaturates toward gray, transitions back to normal when stun expires
  • Applies to both human and AI players; stun clears on goal reset
  • Client prediction in multiplayer respects stun speed reduction

Changes

  • shared/src/types.ts: Added 'stunned' to PlayerState
  • shared/src/engine/types.ts: Added stunnedUntil field and STUN_DURATION_MS/STUN_SPEED_FACTOR constants
  • shared/src/engine/PhysicsEngine.ts: Stun applied on tackle, speed reduction, ball capture/tackle/action blocks, reset cleanup
  • client/src/scenes/GameSceneConstants.ts: Visual constants for stun radius and transition speed
  • client/src/scenes/BaseGameScene.ts: Smooth stun visual with lerped shrink + desaturation
  • client/src/scenes/MultiplayerScene.ts: Client prediction applies stun speed factor
  • client/src/network/NetworkManager.ts: Updated RemotePlayer.state type for 'stunned'

Test plan

  • Single-player: run into opponent while holding ball — player shrinks/grays and moves slowly for 1s
  • While stunned, run over free ball — should not pick it up
  • After 1s, player returns to normal size/color and full speed
  • Score a goal while stunned — stun clears on reset
  • E2E tests pass: npm run test:e2e (14/14 passing)

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant