From faaf4f7e05075e326f4228dafc19b7507f76e740 Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:14:08 -0400 Subject: [PATCH 1/2] Physics and movement integrate the same real time at any frame rate 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. --- changes/physics-frame-rate-invariance.md | 5 +++ .../core/src/movement/characterController.ts | 15 ++++++- packages/core/src/movement/movementModel.ts | 5 ++- .../core/src/movement/movementProbe.test.ts | 13 +++++- .../core/src/movement/playerMovement.test.ts | 2 +- packages/core/src/movement/playerMovement.ts | 4 ++ .../src/physics/physicsBackendConformance.ts | 17 ++++++++ .../core/src/physics/physicsWorld.test.ts | 43 +++++++++++++++++++ packages/core/src/physics/physicsWorld.ts | 32 +++++++++++--- .../rapier/src/createRapierBackend.test.ts | 15 +++++++ packages/rapier/src/rapierBackend.ts | 22 ++++++++-- 11 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 changes/physics-frame-rate-invariance.md diff --git a/changes/physics-frame-rate-invariance.md b/changes/physics-frame-rate-invariance.md new file mode 100644 index 000000000..26a257d9e --- /dev/null +++ b/changes/physics-frame-rate-invariance.md @@ -0,0 +1,5 @@ +### Fixed + +- `@jgengine/rapier` now accumulates `step(dt)` into its fixed substeps instead of stepping once per call with `fixedDt` as the timestep. A 1 s move covered 0.5 s of simulated time at 30 fps and 2.4 s at 144 fps; it now covers 1 s at either rate, matching `PhysicsWorld` and the `PhysicsBackend` contract. +- `PhysicsWorld` bounds-floor friction is Coulomb (`friction × g × dt`) like contact friction, not a 60 Hz multiplicative brake: a crate sliding at 4 m/s travelled 0.165 m on the world floor and 0.970 m on a floor collider, and now travels 0.967 m on both. +- Player and character-controller gravity integrate trapezoidally, so a jump reaches `v²/2g` at any frame rate: the default jump peaked at 0.933 m at 30 fps and 1.026 m at 144 fps, and now at 1.050 m for both. diff --git a/packages/core/src/movement/characterController.ts b/packages/core/src/movement/characterController.ts index 1fea6bfa0..3ede541f9 100644 --- a/packages/core/src/movement/characterController.ts +++ b/packages/core/src/movement/characterController.ts @@ -44,6 +44,11 @@ export interface CharacterMoveInput { crouch?: boolean; /** Body the controller itself is represented by, excluded from its own casts. */ self?: BodyHandle; + /** + * Vertical displacement for this move when the caller already integrated it (a movement model that + * owns jump/gravity feel). Overrides the controller's own `verticalVelocity * dt` for the sweep. + */ + verticalStep?: number; } /** What one move did. */ @@ -259,13 +264,21 @@ export function createCharacterController(initial: CharacterControllerConfig): C remaining = slid; } + const verticalBefore = state.verticalVelocity; if (input.gravity !== undefined) state.verticalVelocity -= input.gravity * dt; + let jumped = false; if (input.jumpVelocity !== undefined && state.grounded) { state.verticalVelocity = input.jumpVelocity; state.grounded = false; + jumped = true; } const wasGrounded = state.grounded; - let vertical = state.verticalVelocity * dt + carry[1]; + // Trapezoidal displacement: the step moves at the average of this step's start and end vertical + // speed, so a jump reaches its analytic apex (v²/2g) at any frame rate. A caller that already + // integrated the vertical motion passes the step explicitly. + const verticalStep = + input.verticalStep ?? (jumped ? state.verticalVelocity : (verticalBefore + state.verticalVelocity) / 2) * dt; + let vertical = verticalStep + carry[1]; if (wasGrounded && vertical <= 0) vertical -= cfg.snapDistance; const { hit: verticalHit } = sweep(backend, pos, height, [0, vertical, 0], self); if (verticalHit !== null && vertical > 0) { diff --git a/packages/core/src/movement/movementModel.ts b/packages/core/src/movement/movementModel.ts index a08ec2607..d650ecc21 100644 --- a/packages/core/src/movement/movementModel.ts +++ b/packages/core/src/movement/movementModel.ts @@ -369,9 +369,12 @@ export function advancePlayerMotion( : motion.verticalVelocity < 0 ? (tuning?.fallGravityScale ?? 1) : 1; + const risingVelocity = motion.verticalVelocity; motion.verticalVelocity -= gravityAcceleration * gravityScale * deltaSeconds; if (motion.verticalVelocity <= 0) motion.jumpRising = false; - motion.jumpOffset += motion.verticalVelocity * deltaSeconds; + // Trapezoidal, not Euler: a jump integrates to v²/2g whatever the frame rate. First-order Euler + // undershot the apex by 11% at 30 fps and 2% at 144 fps, so the same jump reached different heights. + motion.jumpOffset += ((risingVelocity + motion.verticalVelocity) / 2) * deltaSeconds; if (motion.jumpOffset <= 0 && options?.externalGrounding !== true) { motion.jumpOffset = 0; motion.verticalVelocity = 0; diff --git a/packages/core/src/movement/movementProbe.test.ts b/packages/core/src/movement/movementProbe.test.ts index 6ab925169..c5de7c15a 100644 --- a/packages/core/src/movement/movementProbe.test.ts +++ b/packages/core/src/movement/movementProbe.test.ts @@ -33,6 +33,15 @@ describe("measureMovement", () => { expect(measureMovement(FLOATY_PLATFORMER)).toEqual(measureMovement(FLOATY_PLATFORMER)); }); + test("the same jump reaches the same height at 30, 60, 144 and 240 fps", () => { + // Euler gravity over-estimates at large dt: before the trapezoidal step the same jump peaked at + // 0.933 m at 30 fps and 1.026 m at 144 fps — an 11% band from frame rate alone. + const heights = [1 / 30, 1 / 60, 1 / 144, 1 / 240].map((dt) => measureMovement({}, { dt }).jumpHeight); + const spread = Math.max(...heights) - Math.min(...heights); + expect(heights[1]).toBeCloseTo((7.1 * 7.1) / (2 * 24), 2); + expect(spread).toBeLessThan(0.01); + }); + test("same fields, two distinct feels: floaty platformer vs weighty shooter", () => { const floaty = measureMovement(FLOATY_PLATFORMER); const weighty = measureMovement(WEIGHTY_SHOOTER); @@ -51,7 +60,9 @@ describe("measureMovement", () => { expect(floaty.tapJumpHeight).toBeLessThan(floaty.jumpHeight * 0.6); expect(weighty.tapJumpHeight).toBe(weighty.jumpHeight); expect(floaty.airTime - floaty.apexTime).toBeCloseTo(floaty.apexTime, 1); - expect(weighty.airTime - weighty.apexTime).toBeLessThan(weighty.apexTime); + // At 60 Hz tick resolution the 1.6× fall gravity is under one tick; sample finer to see it. + const weightyFine = measureMovement(WEIGHTY_SHOOTER, { dt: 1 / 240 }); + expect(weightyFine.airTime - weightyFine.apexTime).toBeLessThan(weightyFine.apexTime); }); test("sprint raises top speed by the run multiplier", () => { diff --git a/packages/core/src/movement/playerMovement.test.ts b/packages/core/src/movement/playerMovement.test.ts index 69f6e55dd..ba66e0dac 100644 --- a/packages/core/src/movement/playerMovement.test.ts +++ b/packages/core/src/movement/playerMovement.test.ts @@ -1243,7 +1243,7 @@ describe("capsule jump feel", () => { expect(capsuleState.motion!.clockMs).toBe(authoritativeStep ? 200 : 50); expect(capsuleState.motion!.clockMs).toBe(heightfieldState.motion!.clockMs); expect(capsuleState.motion!.verticalVelocity).toBe(heightfieldState.motion!.verticalVelocity); - expect(capsule.scene.entity.get("capsule")!.position[1]).toBeCloseTo(authoritativeStep ? 0.8 : 0.35, 8); + expect(capsule.scene.entity.get("capsule")!.position[1]).toBeCloseTo(authoritativeStep ? 1.2 : 0.375, 8); expect(capsule.scene.entity.get("capsule")!.position[1]).toBeCloseTo(heightfield.scene.entity.get("heightfield")!.position[1], 8); } }); diff --git a/packages/core/src/movement/playerMovement.ts b/packages/core/src/movement/playerMovement.ts index 2a7493e02..40273ea10 100644 --- a/packages/core/src/movement/playerMovement.ts +++ b/packages/core/src/movement/playerMovement.ts @@ -744,6 +744,9 @@ export function stepPlayerMovement( { buffer: state.jumpBuffer, externalGrounding: true, authoritativeStep: tuning.authoritativeStep }, ); state.controllerJumpHeld = intent.jumping; + // `jumpOffset` is this step's vertical displacement under the movement model's trapezoidal + // integration; the controller sweeps that instead of re-deriving `v * dt` from the end velocity. + const verticalStep = horizontal.jumpOffset; controller.restore({ ...controller.state(), verticalVelocity: horizontal.verticalVelocity, @@ -752,6 +755,7 @@ export function stepPlayerMovement( const result = controller.move(tuning.controller.backend, { motion: [horizontalStep.stepX, 0, horizontalStep.stepZ], dt: motionStepSeconds(dt, tuning.authoritativeStep), + verticalStep, }); horizontal.verticalVelocity = controller.state().verticalVelocity; horizontal.jumpOffset = 0; diff --git a/packages/core/src/physics/physicsBackendConformance.ts b/packages/core/src/physics/physicsBackendConformance.ts index 8722602bd..c60a07567 100644 --- a/packages/core/src/physics/physicsBackendConformance.ts +++ b/packages/core/src/physics/physicsBackendConformance.ts @@ -59,6 +59,23 @@ export function runPhysicsBackendConformance(create: () => PhysicsBackend, harne expect(Math.abs(state.velocity[1])).toBeLessThan(0.5); }); + test("the same wall-clock time simulates the same distance at 30, 60 and 144 fps", () => { + // A backend that honours `fixedDt` as one step per call simulates `fixedDt * fps` seconds per + // second of real time — half speed at 30 fps, 2.4x at 144 fps. Horizontal travel ignores the + // integrator, so the distance is exactly `v * realSeconds` for any backend that accumulates. + const backend = create(); + const travelled: number[] = []; + for (const fps of [30, 60, 144]) { + const ball = backend.addBody({ shape: { kind: "sphere", radius: 0.2 }, position: [0, 8, 0], velocity: [10, 0, 0] }); + for (let i = 0; i < fps; i += 1) backend.step(1 / fps); + travelled.push(backend.body(ball)!.position[0]); + backend.removeBody(ball); + } + const spread = Math.max(...travelled) - Math.min(...travelled); + expect(travelled[1]).toBeGreaterThan(8); + expect(spread).toBeLessThan(0.2); + }); + test("raycast reports the floor with an upward normal and the right distance", () => { const backend = create(); const ground = floor(backend); diff --git a/packages/core/src/physics/physicsWorld.test.ts b/packages/core/src/physics/physicsWorld.test.ts index 7f5d5034e..b8a2912e3 100644 --- a/packages/core/src/physics/physicsWorld.test.ts +++ b/packages/core/src/physics/physicsWorld.test.ts @@ -558,3 +558,46 @@ describe("collision events", () => { expect(fired).toBe(settled); }); }); + +describe("resting bodies and floor friction", () => { + function floorWorld(floorIsBounds: boolean): { w: PhysicsWorld; box: number } { + const w = new PhysicsWorld({ + capacity: 16, + bounds: { min: [-50, floorIsBounds ? 0 : -5, -50], max: [50, 50, 50] }, + gravity: -20, + }); + if (!floorIsBounds) w.addBody({ shape: "box", halfExtents: [20, 0.5, 20], position: [0, -0.5, 0], static: true }); + return { w, box: w.addBody({ shape: "box", halfExtents: [0.5, 0.5, 0.5], position: [0, 3, 0] }) }; + } + + test("a resting body stays put: velocity 0, no drift, penetration under 12 mm", () => { + const { w, box } = floorWorld(false); + for (let i = 0; i < 120; i += 1) w.step(1 / 60); + const restY = w.posY[box]!; + const restX = w.posX[box]!; + const penetration = 0.5 - restY; + expect(w.isSleeping(box)).toBe(true); + expect(w.velY[box]).toBe(0); + expect(Math.abs(w.velX[box]!)).toBeLessThan(1e-6); + expect(penetration).toBeGreaterThan(-1e-6); + expect(penetration).toBeLessThan(0.012); + for (let i = 0; i < 240; i += 1) w.step(1 / 60); + expect(w.posY[box]).toBe(restY); + expect(w.posX[box]).toBe(restX); + }); + + test("a body sliding on the world floor stops as fast as on a floor collider", () => { + const travelled: number[] = []; + for (const floorIsBounds of [true, false]) { + const { w, box } = floorWorld(floorIsBounds); + // Settle first: this compares steady sliding friction, not the impact impulse's friction budget. + for (let i = 0; i < 120; i += 1) w.step(1 / 60); + w.setVelocity(box, 4, 0, 0); + for (let i = 0; i < 60; i += 1) w.step(1 / 60); + travelled.push(w.posX[box]!); + } + const [bounds, collider] = travelled as [number, number]; + expect(collider).toBeGreaterThan(0.8); + expect(Math.abs(bounds - collider)).toBeLessThan(collider * 0.15); + }); +}); diff --git a/packages/core/src/physics/physicsWorld.ts b/packages/core/src/physics/physicsWorld.ts index a565b443c..793b68cf7 100644 --- a/packages/core/src/physics/physicsWorld.ts +++ b/packages/core/src/physics/physicsWorld.ts @@ -736,7 +736,7 @@ export class PhysicsWorld { this.posX[i]! += this.velX[i]! * dt; this.posY[i]! += this.velY[i]! * dt; this.posZ[i]! += this.velZ[i]! * dt; - this.constrainBounds(i); + this.constrainBounds(i, dt, true); } this.buildGrid(); const iterations = this.solverIterations; @@ -744,11 +744,11 @@ export class PhysicsWorld { this.solve(it === 0, it === iterations - 1); if (this.jointActiveCount > 0) this.solveJoints(dt); } - for (let a = 0; a < this.awakeCount; a += 1) this.constrainBounds(this.awakeList[a]!); + for (let a = 0; a < this.awakeCount; a += 1) this.constrainBounds(this.awakeList[a]!, dt, false); this.updateSleep(); } - private constrainBounds(i: number): void { + private constrainBounds(i: number, dt: number, frictionPass: boolean): void { const min = this.bounds.min; const max = this.bounds.max; const e = this.restitution; @@ -776,15 +776,35 @@ export class PhysicsWorld { if (this.posY[i]! < loY) { this.posY[i] = loY; if (this.velY[i]! < 0) this.velY[i]! = -this.velY[i]! < rt ? 0 : this.velY[i]! * -e; - const tf = 1 - this.friction; - this.velX[i]! *= tf; - this.velZ[i]! *= tf; + if (frictionPass) this.applyFloorFriction(i, dt); } else if (this.posY[i]! > hiY) { this.posY[i] = hiY; if (this.velY[i]! > 0) this.velY[i]! = this.velY[i]! < rt ? 0 : -this.velY[i]! * e; } } + /** + * Coulomb friction against the bounds floor, using the same `friction` coefficient a contact + * applies: horizontal speed bleeds by `friction * |gravity| * dt` per substep. The old + * multiplicative form (`* (1 - friction)` every substep) removed 60 × 40% per second, so a body + * on the world floor stopped ~6× shorter than the same body on a static floor collider. + */ + private applyFloorFriction(i: number, dt: number): void { + const decel = this.friction * Math.abs(this.gravity) * dt; + if (!(decel > 0)) return; + const vx = this.velX[i]!; + const vz = this.velZ[i]!; + const speed = Math.sqrt(vx * vx + vz * vz); + if (speed <= decel) { + this.velX[i] = 0; + this.velZ[i] = 0; + return; + } + const keep = (speed - decel) / speed; + this.velX[i] = vx * keep; + this.velZ[i] = vz * keep; + } + /** * Walk every broadphase cell body `i`'s AABB overlaps, invoking `visit(cell)` once per cell. The * cell-range math (six `cellCoord` calls plus the row-major triple loop) lives here so the count diff --git a/packages/rapier/src/createRapierBackend.test.ts b/packages/rapier/src/createRapierBackend.test.ts index 2afa904a9..6940fd385 100644 --- a/packages/rapier/src/createRapierBackend.test.ts +++ b/packages/rapier/src/createRapierBackend.test.ts @@ -36,6 +36,21 @@ describe("createRapierBackend", () => { } }); + test("fixedDt accumulates real time: 30 fps and 144 fps cover the same distance", async () => { + // The backend used to set `world.timestep = fixedDt` and step once per call, so 1 s of real time + // at 30 fps simulated 0.5 s and at 144 fps simulated 2.4 s — speed scaled with frame rate. + const travelled: number[] = []; + for (const fps of [30, 144]) { + const backend = await createRapierBackend({ gravity: [0, 0, 0], fixedDt: 1 / 60 }); + const ball = backend.addBody({ shape: { kind: "sphere", radius: 0.2 }, position: [0, 0, 0], velocity: [10, 0, 0] }); + for (let i = 0; i < fps; i += 1) backend.step(1 / fps); + travelled.push(backend.body(ball)!.position[0]); + backend.dispose(); + } + expect(travelled[0]).toBeCloseTo(10, 0); + expect(Math.abs(travelled[0]! - travelled[1]!)).toBeLessThan(0.2); + }); + test("character controller lands and walks", async () => { const backend = await world(); const c = controller(); diff --git a/packages/rapier/src/rapierBackend.ts b/packages/rapier/src/rapierBackend.ts index 50b728517..80565124f 100644 --- a/packages/rapier/src/rapierBackend.ts +++ b/packages/rapier/src/rapierBackend.ts @@ -34,6 +34,11 @@ export async function createRapierBackend(options: RapierBackendOptions = {}): P let hasStepped = false; let listener: ((event: ContactEvent) => void) | null = null; const forced = new Set(); + // `step(dt)` is accumulated into equal fixed slices, as the backend contract promises; the + // default matches PhysicsWorld so the two backends simulate the same real time per frame. + const fixedDt = options.fixedDt ?? 1 / 60; + const maxSubsteps = Math.max(1, options.maxSubsteps ?? 4); + let accumulator = 0; const shape = (s: BodyShape): any => { switch (s.kind) { @@ -122,10 +127,21 @@ export async function createRapierBackend(options: RapierBackendOptions = {}): P }, removeJoint(h) { const j = joints.get(h); if (j) { world.removeImpulseJoint(j.joint, true); joints.delete(h); } }, step(dt) { - if (dt <= 0) return; world.timestep = options.fixedDt ?? dt; world.step(events); hasStepped = true; - // Rapier keeps added forces until reset; the backend contract is one step per applyForce call. + if (!(dt > 0)) return; + const advance = (): void => { + world.step(events); + hasStepped = true; + events.drainCollisionEvents((rawX: number, rawY: number, started: boolean) => { if (!listener || !started) return; const x = eventHandle(rawX), y = eventHandle(rawY), ca = world.getCollider(x), cb = world.getCollider(y), ha = find(colliders, x), hb = find(colliders, y); if (!ca || !cb || ha === undefined || hb === undefined) return; const m = world.contactPair(ca, cb)?.manifolds?.()[0], n = vec(m?.normal?.() ?? { x: 0, y: 1, z: 0 }), va = ca.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }, vb = cb.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }; listener({ a: ha, b: hb, normal: n, approachSpeed: Math.max(0, -((vb.x - va.x) * n[0] + (vb.y - va.y) * n[1] + (vb.z - va.z) * n[2])), impulse: Number(m?.totalImpulse?.() ?? 0) }); }); + }; + accumulator += dt; + const cap = fixedDt * maxSubsteps; + if (accumulator > cap) accumulator = cap; + world.timestep = fixedDt; + while (accumulator >= fixedDt) { + advance(); + accumulator -= fixedDt; + } // Rapier keeps added forces until reset; the backend contract is one step per applyForce call. for (const h of forced) { const b = bodies.get(h); if (b) { b.resetForces(false); b.resetTorques(false); } } forced.clear(); - events.drainCollisionEvents((rawX: number, rawY: number, started: boolean) => { if (!listener || !started) return; const x = eventHandle(rawX), y = eventHandle(rawY), ca = world.getCollider(x), cb = world.getCollider(y), ha = find(colliders, x), hb = find(colliders, y); if (!ca || !cb || ha === undefined || hb === undefined) return; const m = world.contactPair(ca, cb)?.manifolds?.()[0], n = vec(m?.normal?.() ?? { x: 0, y: 1, z: 0 }), va = ca.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }, vb = cb.parent()?.linvel?.() ?? { x: 0, y: 0, z: 0 }; listener({ a: ha, b: hb, normal: n, approachSpeed: Math.max(0, -((vb.x - va.x) * n[0] + (vb.y - va.y) * n[1] + (vb.z - va.z) * n[2])), impulse: Number(m?.totalImpulse?.() ?? 0) }); }); }, raycast(d: RayDesc) { const n = Math.hypot(...d.direction); if (!n) return null; From f6eb7b95ef02b77662853af869f85275c35eb2ad Mon Sep 17 00:00:00 2001 From: NoisemakerJon <139656120+Noisemaker111@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:27:50 -0400 Subject: [PATCH 2/2] Compare the weighty fall against its rise with an explicit one-tick tolerance 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. --- apps/dev/src/demo/walkFeelTuning.test.ts | 9 ++++++++- packages/core/src/movement/movementProbe.test.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/dev/src/demo/walkFeelTuning.test.ts b/apps/dev/src/demo/walkFeelTuning.test.ts index 957115846..376ad7433 100644 --- a/apps/dev/src/demo/walkFeelTuning.test.ts +++ b/apps/dev/src/demo/walkFeelTuning.test.ts @@ -20,7 +20,14 @@ describe("walk feel demo characters", () => { expect(weighty.jumpHeight).toBeLessThan(0.6); expect(weighty.airTime).toBeLessThan(0.4); expect(weighty.airControlReach).toBeLessThan(0.5); - expect(weighty.airTime - weighty.apexTime).toBeLessThan(weighty.apexTime); + // At 60 Hz the fall and the rise land on the same tick (both 0.1833 s), so this comparison sits on + // its own boundary. Sample at 240 Hz, where the 1.6x fall gravity resolves to 0.1667 s fall against + // a 0.1875 s rise. The tolerance is one 240 Hz tick — the sampling resolution — because anything + // smaller fails a legitimate next-tick rounding and anything larger (1/60) would swallow the signal. + const fine = measureMovement(WEIGHTY_FEEL, { dt: 1 / 240 }); + const rise = fine.apexTime; + const fall = fine.airTime - fine.apexTime; + expect(fall, `fall ${fall.toFixed(4)}s against rise ${rise.toFixed(4)}s at 240 Hz`).toBeLessThan(rise - 1 / 240); expect(weighty.timeToTopSpeed).toBeGreaterThan(0.2); expect(weighty.stopDistance).toBeGreaterThan(0.2); }); diff --git a/packages/core/src/movement/movementProbe.test.ts b/packages/core/src/movement/movementProbe.test.ts index c5de7c15a..aa44f9840 100644 --- a/packages/core/src/movement/movementProbe.test.ts +++ b/packages/core/src/movement/movementProbe.test.ts @@ -60,9 +60,13 @@ describe("measureMovement", () => { expect(floaty.tapJumpHeight).toBeLessThan(floaty.jumpHeight * 0.6); expect(weighty.tapJumpHeight).toBe(weighty.jumpHeight); expect(floaty.airTime - floaty.apexTime).toBeCloseTo(floaty.apexTime, 1); - // At 60 Hz tick resolution the 1.6× fall gravity is under one tick; sample finer to see it. + // At 60 Hz tick resolution the 1.6× fall gravity is under one tick; sample finer to see it. The + // tolerance is one 240 Hz tick (the sampling resolution), so a fall slower than the rise by more + // than a tick still fails while a next-tick rounding does not. const weightyFine = measureMovement(WEIGHTY_SHOOTER, { dt: 1 / 240 }); - expect(weightyFine.airTime - weightyFine.apexTime).toBeLessThan(weightyFine.apexTime); + const rise = weightyFine.apexTime; + const fall = weightyFine.airTime - weightyFine.apexTime; + expect(fall, `fall ${fall.toFixed(4)}s against rise ${rise.toFixed(4)}s at 240 Hz`).toBeLessThan(rise - 1 / 240); }); test("sprint raises top speed by the run multiplier", () => {