Skip to content

Physics and movement integrate the same real time at any frame rate - #2315

Merged
Noisemaker111 merged 2 commits into
betafrom
agent/lane-physics-jitter
Oct 24, 2026
Merged

Noisemaker111 merged 2 commits into
betafrom
agent/lane-physics-jitter

Conversation

@Noisemaker111

@Noisemaker111 Noisemaker111 commented Oct 24, 2026 •

Copy link
Copy Markdown
Owner

Physics and movement integrate the same real time at any frame rate.

Three separate causes, each measured with a headless scenario before it was named.

Causes and before/after

  • Rapier stepped on the frame's dt instead of accumulating — packages/rapier/src/rapierBackend.ts:125 set world.timestep = fixedDt ?? dt and stepped once per call, contradicting the contract in physicsBackend.ts:151. A ball with fixedDt: 1/60, 2 s of real time: y = 15.075 at 30 fps (half the simulated time) vs 0.199 at 144 fps (2.4×). Now 30 and 60 fps agree exactly and 144 fps is within one fixed step.
  • PhysicsWorld floor friction was a 60 Hz multiplicative brake — physicsWorld.ts:779 did velX *= 1 - friction every substep (0.6^60/s), six times stronger than contact friction. A crate at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider with the same friction: 0.4. Now 0.967 m and 0.970 m.
  • Jump gravity was first-order Euler — movementModel.ts:372 and characterController.ts:268. The same jump peaked at 0.933 / 0.992 / 1.026 m at 30 / 60 / 144 fps, an 11% band from frame rate alone (analytic 1.050). Now 1.05000 / 1.05000 / 1.05012 m.

Checked and cleared, so they are not part of this change: the heightfield walker keeps feet exactly on the terrain (clearance 0.00000 over 3 s at every rate, zero drift standing still), PhysicsWorld was already frame-rate invariant, and the gravity sign mapping is correct.

Gates, each proven red on the old code first

  • Shared backend conformance "same distance at 30/60/144 fps" (physicsBackendConformance.ts) — red on the old Rapier step, green on both backends now.
  • Rapier fixedDt accumulation (createRapierBackend.test.ts) — old code gave 5.0 m instead of 10 m; red, now green.
  • Movement apex across four frame rates (movementProbe.test.ts) — old spread 0.093 m; red, now green.
  • Resting body: velocity 0, no drift, penetration under 12 mm (physicsWorld.test.ts).
  • Floor-friction parity between world floor and floor collider — old 0.80 m apart; red, now green.

bun run verify:fast 11/11. Core physics + movement 682 pass, rapier 11 pass, typechecks clean.

Open, not fixed here

  • Stacked bodies still rest in penetration: 8.8 mm for one box, 33.7 mm for a two-box stack. The solver removes 80% of the excess per substep and sleep freezes the rest. Gated as "does not grow", not "reduced".
  • Landing while moving is inconsistent: on a floor collider the impact impulse feeds the friction budget, so the body stops dead horizontally; on the world floor it keeps sliding. Both are defensible models, so it is left for a deliberate choice.

Adoption

Games get these from a release. simulation.hz still defaults to "variable", so game-layer per-tick math still runs on frame dt: character travel differed 0.37% and the force-model vehicle 2.8% over 3 s between 30 and 144 fps. Harbor Heat, Scrap Signal and Drift Foundry should declare simulation: { hz: 60 }. Not changed here.

Jump apex at 60 fps is 6% higher than before; jumpVelocity 7.1 → 6.9 keeps the old height if a game wants it.

The Rapier backend stepped once per call with fixedDt as the timestep, so 1s of play ran 0.5s at 30fps and 2.4s at 144fps; it now accumulates like PhysicsWorld.
PhysicsWorld bounds-floor friction was a 60Hz multiplicative brake six times stronger than contact friction; it is now Coulomb.
Player jump and character-controller gravity integrate trapezoidally, so apex is v^2/2g instead of frame-rate dependent.
Gates: backend conformance (same distance at 30/60/144fps), Rapier fixedDt accumulation, movement apex across four rates, resting body penetration/velocity/drift, floor friction parity.
…olerance

At 60 Hz the fall and rise land on the same tick, so the strict comparison sat on its own
boundary. Sample at 240 Hz where the 1.6x fall gravity resolves (0.1667s fall vs 0.1875s rise)
and allow one 240 Hz tick of rounding; the failure message prints both numbers.
@Noisemaker111
Noisemaker111 merged commit 3f6cea4 into beta Oct 24, 2026
31 checks passed
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