diff --git a/backend/src/objects/character-physical.ts b/backend/src/objects/character-physical.ts index de85b1d..ae72c0f 100644 --- a/backend/src/objects/character-physical.ts +++ b/backend/src/objects/character-physical.ts @@ -12,6 +12,7 @@ import { UpdatePropertyCommand, CommandExecutors, CommandReceiver, + CharacterMovementSnapshot, mix, clamp01, stepCharacterMovement, @@ -209,10 +210,18 @@ export class CharacterPhysical extends CharacterBase implements DynamicPhysical return this.currentPlanet; } - // Persistent launch momentum, streamed to the owning client so its predictor - // can reproduce a leap/slingshot/recoil flight instead of only snapping to it. - public get launchMomentum(): vec2 { - return this.bodyVelocity; + // The continuous movement state streamed to the owning client, so its + // predictor resumes this simulation instead of guessing at the parts of it + // the pose does not show. See CharacterMovementSnapshot. + public get movementSnapshot(): CharacterMovementSnapshot { + return new CharacterMovementSnapshot( + this.direction, + vec2.clone(this.bodyVelocity), + vec2.clone(this.leftFoot.lastNormal), + vec2.clone(this.rightFoot.lastNormal), + this.currentPlanet?.id ?? null, + this.secondsSinceOnSurface, + ); } public addKill(victimName: string, charge = 0) { diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index 459a88e..0a2ca8b 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -283,7 +283,7 @@ export class Player extends PlayerBase { this.queueCommandSend( new InputAcknowledgement( this.lastInputClientTimeMs, - this.character.launchMomentum, + this.character.movementSnapshot, this.lastLeapClientTimeMs, this.lastInputReceiptMs > 0 ? Math.max(0, performance.now() - this.lastInputReceiptMs) @@ -369,6 +369,10 @@ export class Player extends PlayerBase { const sheddable: ReadonlyArray = [ PropertyUpdatesForObjects.type, UpdateMinimap.type, + // Only meaningful together with the pose it describes, and regenerated + // in full next tick — so it is shed with it rather than advancing the + // client's replay anchor past a snapshot it never received. + InputAcknowledgement.type, ]; this.commandsToBeSent = this.commandsToBeSent.filter( (c) => !sheddable.includes(c.type), diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index b63ad51..8fb4379 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -36,7 +36,10 @@ import { PlanetShape } from './shapes/planet-shape'; import { RenderCommand } from './commands/types/render'; import { StepCommand } from './commands/types/step'; import { serverTimeline } from './helper/server-timeline'; -import { localCharacterPredictor } from './helper/prediction/local-character-predictor'; +import { + localCharacterPredictor, + setFrameTimeMs, +} from './helper/prediction/local-character-predictor'; import { Tutorial } from './tutorial'; import { Scoreboard } from './scoreboard'; import { Minimap } from './minimap'; @@ -226,7 +229,7 @@ export class Game extends CommandReceiver { [InputAcknowledgement.type]: (c: InputAcknowledgement) => localCharacterPredictor.acknowledge( c.clientTimeMs, - c.bodyVelocity, + c.movement, c.lastLeapClientTimeMs, c.ackAgeMs, ), @@ -334,10 +337,16 @@ export class Game extends CommandReceiver { private framesSinceLastLayoutUpdate = 0; private gameLoop( renderer: Renderer, - _: DOMHighResTimeStamp, + currentTime: DOMHighResTimeStamp, deltaTime: DOMHighResTimeStamp, ): boolean { this.resolveStarted(); + // The client's one clock, for input stamps, the outgoing send cadence and + // the prediction replay window alike. It has to be the frame's timestamp + // rather than performance.now(): the two differ by however long the browser + // took to dispatch this callback, and that difference would land straight + // in the replay window and jitter the predicted body. + setFrameTimeMs(currentTime); deltaTime /= 1000; // Decay the camera impact effects on raw wall-clock time, before any of the diff --git a/frontend/src/scripts/helper/prediction/client-character-world.ts b/frontend/src/scripts/helper/prediction/client-character-world.ts index a08dbc5..b09390d 100644 --- a/frontend/src/scripts/helper/prediction/client-character-world.ts +++ b/frontend/src/scripts/helper/prediction/client-character-world.ts @@ -118,10 +118,6 @@ export class ClientCharacterWorld implements CharacterWorld { return id == null ? undefined : this.surfaces.get(id); } - public idOf(surface: GroundSurface | undefined): Id | undefined { - return surface instanceof PlanetSurface ? surface.id : undefined; - } - public groundsNear(): Array { return this.ordered; } diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index 5862347..5363224 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -1,8 +1,8 @@ import { vec2 } from 'gl-matrix'; import { Circle, + CharacterMovementSnapshot, CharacterMovementState, - Id, PhysicsBody, settings, stepCharacterMovement, @@ -17,18 +17,22 @@ import { InputHistory } from './input-history'; const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick const stepSeconds = 1 / 200; -// Clock source for the predictor. Injectable so deterministic reconciliation -// tests can drive prediction without real time passing; defaults to the -// browser wall clock in production. -let nowMs: () => number = () => performance.now(); -export const setPredictorClockForTesting = (clock: () => number): void => { - nowMs = clock; +// The client's clock: the timestamp of the frame currently being rendered, set +// once per frame by the game loop (requestAnimationFrame hands it to us +// already). Everything time-related on the client reads it from here, so input +// stamps, the outgoing send cadence and the replay window all sit on one +// timeline. +// +// Sampling performance.now() ad hoc instead — which is what this used to do — +// reads the frame's *dispatch* time, and the few ms of scheduling jitter +// between the frame's timestamp and the callback actually running went straight +// into the replay window. The body then advanced by a slightly different amount +// than the frame it was drawn in. +let frameTimeMs = 0; +export const setFrameTimeMs = (timeMs: number): void => { + frameTimeMs = timeMs; }; - -// The same clock the predictor stamps input with. Anything that tells the server -// "this is how far my input timeline has got" must read it from here, or the -// acknowledgement comes back in a different time base than the replay window. -export const predictorNowMs = (): number => nowMs(); +export const predictorNowMs = (): number => frameTimeMs; // Don't replay more than this far back: if the last acknowledged input is older // (a stall, or a backgrounded tab catching up) fall back to a shorter window @@ -46,11 +50,15 @@ const smoothSeconds = 0.06; // the predictor doesn't model. Snap to it rather than gliding across the gap. const snapDistance = 250; -const makeBody = (center: vec2, radius: number): PhysicsBody => ({ +const upwards = vec2.fromValues(0, 1); + +// Velocity is deliberately zero: the movement simulation rebuilds it from +// scratch every tick and zeroes it again at the end, so it is not carried state. +const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({ center: vec2.clone(center), radius, velocity: vec2.create(), - lastNormal: vec2.fromValues(0, 1), + lastNormal: vec2.fromValues(normal[0], normal[1]), restitution: 0, }); @@ -77,12 +85,12 @@ export class LocalCharacterPredictor { // snapshot was actually taken. Both halves are needed. // // Anchoring on the acknowledged input time ALONE beats against the send - // cadence: input is sent once per frame, so at snapshot time the newest input - // the server holds is 0..1 frame old depending on where the client's frames - // fell, and that age lands directly in the replay window. Measured on - // localhost it walked a sawtooth — 17, 10, 6, 1, 11, 6, 2, 12 ms — so the - // window jumped by up to a frame of travel every snapshot and the body - // stuttered at 25 Hz. ackAgeMs cancels it exactly. + // cadence: at snapshot time the newest input the server holds is 0..1 send + // interval old depending on where the client's sends fell, and that age lands + // directly in the replay window. Measured on localhost it walked a sawtooth — + // 17, 10, 6, 1, 11, 6, 2, 12 ms — so the window jumped by up to a frame of + // travel every snapshot and the body stuttered at 25 Hz. ackAgeMs cancels it + // exactly. // // Anchoring on the snapshot's ARRIVAL is free of that beat, but it under- // advances by a one-way trip, so the local body trails its own input at any @@ -93,14 +101,17 @@ export class LocalCharacterPredictor { // altogether; that is fixed at the source, by closing every outgoing batch // with a ClientHeartbeatCommand. private replayAnchorMs?: number; - // Authoritative launch momentum at the last snapshot — seeds each replay so a - // leap/slingshot/recoil flight is reproduced and continuously corrected. - private authoritativeBodyVelocity = vec2.create(); - // Wall-clock times the player issued a leap, replayed (with the impulse - // applied locally) so the launch is felt immediately, not after a round trip. + // The rest of the server's movement state at that same instant: facing + // direction, launch momentum, foot contact normals and the latched planet. + // Together with the pose above it is everything stepCharacterMovement carries + // between ticks, so a replay continues the server's simulation instead of + // approximating it. + private movement?: CharacterMovementSnapshot; + // Frame times the player issued a leap, replayed (with the impulse applied + // locally) so the launch is felt immediately, not after a round trip. private leapHistory: Array = []; // clientTimeMs of the last leap the server has folded into the streamed - // momentum. Leaps at or before this are already in authoritativeBodyVelocity; + // momentum. Leaps at or before this are already in movement.bodyVelocity; // only newer ones are replayed, so a leap is never applied twice. private lastLeapAckMs = -Infinity; // Latest streamed shooting-strength, to gate predicted leaps as the server does. @@ -112,13 +123,6 @@ export class LocalCharacterPredictor { // that still moved would be a pure client-side desync. private alive = true; - // Continuous state carried between replays (the snapshot carries only poses). - // The facing direction is NOT carried — it is re-derived from the pose each - // frame (see directionFromPose / simulate); only the latched planet and the - // time-since-surface persist. - private carriedPlanetId?: Id; - private carriedSecondsSinceSurface = 1; - // The eased, rendered pose handed to the view. private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); @@ -135,27 +139,27 @@ export class LocalCharacterPredictor { return this.renderRightFoot; } - // Stamp a movement command and record it for replay. Returns the wall-clock + // Stamp a movement command and record it for replay. Returns the client-clock // time the command should carry so the server can echo it back. public recordInput(direction: vec2): number { - const timeMs = Math.round(nowMs()); + const timeMs = Math.round(frameTimeMs); this.inputHistory.record(direction, timeMs); return timeMs; } public acknowledge( clientTimeMs: number, - bodyVelocity: vec2, + movement: CharacterMovementSnapshot, lastLeapClientTimeMs: number, ackAgeMs = 0, ): void { - // Inputs only advance the acknowledgement forward; the launch momentum and + // Inputs only advance the acknowledgement forward; the movement state and // leap boundary always adopt the latest authoritative values. const anchor = clientTimeMs + Math.max(0, ackAgeMs); if (this.replayAnchorMs === undefined || anchor > this.replayAnchorMs) { this.replayAnchorMs = anchor; } - vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]); + this.movement = movement; this.lastLeapAckMs = lastLeapClientTimeMs; } @@ -168,7 +172,7 @@ export class LocalCharacterPredictor { // it — a rejected leap (no strength/cooldown) self-corrects via the streamed // authoritative momentum. public recordLeap(): number { - const timeMs = Math.round(nowMs()); + const timeMs = Math.round(frameTimeMs); this.leapHistory.push(timeMs); const cutoff = timeMs - 1500; while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) { @@ -191,16 +195,18 @@ export class LocalCharacterPredictor { this.lastLeapAckMs = -Infinity; this.authoritative = undefined; this.replayAnchorMs = undefined; - vec2.zero(this.authoritativeBodyVelocity); + this.movement = undefined; this.currentStrength = settings.playerMaxStrength; - this.carriedPlanetId = undefined; - this.carriedSecondsSinceSurface = 1; this.hasRender = false; this.alive = true; } public get canPredict(): boolean { - return this.authoritative !== undefined && this.replayAnchorMs !== undefined; + return ( + this.authoritative !== undefined && + this.movement !== undefined && + this.replayAnchorMs !== undefined + ); } // During spawn-in and death the server freezes walking and only scales the @@ -237,22 +243,10 @@ export class LocalCharacterPredictor { return true; } - // The body's facing angle is encoded in the pose: each part is sprung toward - // center + R(direction)*offset and the head's offset points +y, so - // direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot - // each frame (rather than carrying the previous frame's evolved value onto - // this past pose, re-evolved by a variable substep count) keeps the posture - // seed a pure function of the snapshot — carrying it fed a frame-rate-dependent - // loop that wobbled the rendered limbs. - private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number { - const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3; - const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3; - return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2; - } - private simulate(): CharacterMovementState { const auth = this.authoritative!; - const now = Math.round(nowMs()); + const movement = this.movement!; + const now = Math.round(frameTimeMs); // Replay every input the server has not confirmed yet: from the anchor up to // now, clamped so a stall (or a backgrounded tab catching up) cannot grind // through hundreds of steps. @@ -261,16 +255,30 @@ export class LocalCharacterPredictor { const steps = Math.floor(windowMs / stepMs); const remainderSeconds = (windowMs - steps * stepMs) / 1000; + // The full authoritative state: the pose from the property updates, the + // rest from the acknowledgement. Nothing here is inferred or carried over + // from the previous frame, so a replay is a pure function of (snapshot, + // input history, window) and cannot drift. const state: CharacterMovementState = { - head: makeBody(auth.head.center, auth.head.radius), - leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius), - rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius), - direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot), - currentPlanet: this.world.surfaceById(this.carriedPlanetId), - secondsSinceOnSurface: this.carriedSecondsSinceSurface, + // The head's contact normal is written by the collision but never read by + // the movement, so it does not need to travel. + head: makeBody(auth.head.center, auth.head.radius, upwards), + leftFoot: makeBody( + auth.leftFoot.center, + auth.leftFoot.radius, + movement.leftFootNormal, + ), + rightFoot: makeBody( + auth.rightFoot.center, + auth.rightFoot.radius, + movement.rightFootNormal, + ), + direction: movement.direction, + currentPlanet: this.world.surfaceById(movement.groundPlanetId), + secondsSinceOnSurface: movement.secondsSinceOnSurface, // Leaps before the replay window are already baked into this; leaps inside // the window are re-applied below, so neither is double-counted. - bodyVelocity: vec2.clone(this.authoritativeBodyVelocity), + bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]), }; // The planet collision frames were synced (in update(), just before this) @@ -332,9 +340,6 @@ export class LocalCharacterPredictor { this.world.advance(remainderSeconds); } - this.carriedPlanetId = this.world.idOf(state.currentPlanet); - this.carriedSecondsSinceSurface = state.secondsSinceOnSurface; - return state; } diff --git a/shared/src/commands/types/input-acknowledgement.ts b/shared/src/commands/types/input-acknowledgement.ts index f11021b..077ac4d 100644 --- a/shared/src/commands/types/input-acknowledgement.ts +++ b/shared/src/commands/types/input-acknowledgement.ts @@ -1,29 +1,72 @@ import { vec2 } from 'gl-matrix'; +import { Id } from '../../communication/id'; import { serializable } from '../../serialization/serializable'; import { Command } from '../command'; +/** + * Everything the character's movement simulation carries from one tick to the + * next that is *not* in the pose. + * + * The pose (head/feet circles) travels as ordinary property updates because + * every viewer needs it; this travels to the owning client only. Together they + * are the complete state `stepCharacterMovement` needs, so the predictor can + * resume the server's simulation rather than approximate it. + * + * It all has to be here. `direction` in particular is not recoverable from the + * pose: the body is sprung *towards* the posture that direction implies and + * lags it while walking, so deriving the angle back out of a walking pose is + * off by tens of degrees — and every replay then spent its first ticks + * twisting the body back, by an amount that varied with the replay length. + */ +@serializable +export class CharacterMovementSnapshot { + public constructor( + public readonly direction: number, + // Persistent launch momentum (leap / slingshot / recoil), so the predictor + // reproduces a flight rather than only snapping to it. + public readonly bodyVelocity: vec2, + // Foot contact normals: the on-surface branch projects gravity onto these, + // so a body standing on a slope needs the real ones. + public readonly leftFootNormal: vec2, + public readonly rightFootNormal: vec2, + // The planet being stood on, or null while airborne. + public readonly groundPlanetId: Id, + public readonly secondsSinceOnSurface: number, + ) {} + + public toArray(): Array { + return [ + this.direction, + this.bodyVelocity, + this.leftFootNormal, + this.rightFootNormal, + this.groundPlanetId, + this.secondsSinceOnSurface, + ]; + } +} + // Sent server -> owning client only, alongside that client's own character -// snapshot. Carries the clientTimeMs of the most recent movement input the -// server had received when the snapshot was taken (so the client's predictor -// can reset to the snapshot and replay just the inputs the server hasn't seen -// yet) and the authoritative launch momentum (so the predictor reproduces a -// leap/slingshot/recoil flight rather than only snapping to it). See +// snapshot. Carries the clientTimeMs of the most recent input the server had +// received when the snapshot was taken (so the client's predictor can reset to +// the snapshot and replay just the inputs the server hasn't seen yet) and the +// authoritative movement state the replay resumes from. See // LocalCharacterPredictor. @serializable export class InputAcknowledgement extends Command { public constructor( public readonly clientTimeMs: number, - public readonly bodyVelocity: vec2, + public readonly movement: CharacterMovementSnapshot, // clientTimeMs of the most recent leap the server has received: any leap at - // or before it is already reflected in bodyVelocity, so the predictor must - // not replay it. + // or before it is already reflected in movement.bodyVelocity, so the + // predictor must not replay it. public readonly lastLeapClientTimeMs: number, // How long before this snapshot the acknowledged input arrived. The client - // sends at its frame rate, so by the time a snapshot is taken the newest - // input it has is already 0..1 frame old — and that age varies with where - // the client's frames happened to fall between two snapshots. Adding it - // back places the replay anchor at the snapshot instant itself instead of - // at whichever input happened to land last, which is what keeps the + // sends at a fixed cadence, so by the time a snapshot is taken the newest + // input it has is already 0..1 send interval old — and that age varies with + // where the client's sends happened to fall between two snapshots. Adding + // it back places the replay anchor at the snapshot instant itself instead + // of at whichever input happened to land last, which is what keeps the // predicted pose free of the send-cadence beat. See LocalCharacterPredictor. public readonly ackAgeMs: number = 0, ) { @@ -31,11 +74,6 @@ export class InputAcknowledgement extends Command { } public toArray(): Array { - return [ - this.clientTimeMs, - this.bodyVelocity, - this.lastLeapClientTimeMs, - this.ackAgeMs, - ]; + return [this.clientTimeMs, this.movement, this.lastLeapClientTimeMs, this.ackAgeMs]; } } diff --git a/shared/src/helper/rotate-minus-90-deg.ts b/shared/src/helper/rotate-minus-90-deg.ts deleted file mode 100644 index 3f4773f..0000000 --- a/shared/src/helper/rotate-minus-90-deg.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { vec2 } from 'gl-matrix'; - -export const rotateMinus90Deg = (vec: vec2): vec2 => vec2.fromValues(vec[1], -vec[0]); diff --git a/shared/src/helper/wait.ts b/shared/src/helper/wait.ts deleted file mode 100644 index 73dd2c2..0000000 --- a/shared/src/helper/wait.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const wait = (ms: number): Promise => { - return new Promise((resolve, _) => setTimeout(resolve, ms)); -}; diff --git a/test/client-send-cadence.test.ts b/test/client-send-cadence.test.ts index d80bc3a..61fa4f4 100644 --- a/test/client-send-cadence.test.ts +++ b/test/client-send-cadence.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'node:module'; import { CommandSocket } from '../frontend/src/scripts/commands/command-socket'; -import { setPredictorClockForTesting } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; +import { setFrameTimeMs } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; const require = createRequire(import.meta.url); const shared = require('../shared/lib/main.js'); @@ -18,16 +18,13 @@ applyArrayPlugins(); // Collects what would go on the wire. const drive = (frameRate: number, seconds: number, onFrame?: (socket: any) => void) => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const batches: Array = []; const socket = { emit: (_event: string, payload: string) => batches.push(payload) }; const commandSocket = new CommandSocket(socket as never); const frames = Math.round(frameRate * seconds); for (let f = 0; f < frames; f++) { - clock = (f * 1000) / frameRate; + setFrameTimeMs((f * 1000) / frameRate); onFrame?.(commandSocket); commandSocket.sendQueuedCommands(); } diff --git a/test/prediction-reconciliation.test.ts b/test/prediction-reconciliation.test.ts index b3b4a5c..f155de6 100644 --- a/test/prediction-reconciliation.test.ts +++ b/test/prediction-reconciliation.test.ts @@ -1,15 +1,16 @@ // Reconciliation guard for the REAL client predictor. It exercises -// LocalCharacterPredictor end-to-end with an injected clock, verifying the two +// LocalCharacterPredictor end-to-end with a driven frame clock, verifying the // properties reconciliation depends on: // 1. a fully-acknowledged snapshot reproduces the authoritative pose exactly -// (no spurious drift on top of server truth), and -// 2. un-acknowledged input is replayed forward deterministically. +// (no spurious drift on top of server truth), +// 2. un-acknowledged input is replayed forward deterministically, and +// 3. the replayed body advances smoothly as the window grows. // This is the net for prediction-touching changes (the death-while-dead fix, // future netcode work) — a regression shows up as drift or non-determinism. import { describe, it, expect } from 'vitest'; import { LocalCharacterPredictor, - setPredictorClockForTesting, + setFrameTimeMs, } from '../frontend/src/scripts/helper/prediction/local-character-predictor'; const HEAD_RADIUS = 50; @@ -23,18 +24,33 @@ const poseAt = (cx: number, cy: number) => ({ rightFoot: { center: [cx + 33, cy - 18], radius: FEET_RADIUS }, }); +// The authoritative movement state that rides along with every snapshot: the +// pose is only half of what the simulation carries between ticks. Upright, +// airborne and with no launch momentum unless a test says otherwise. +const movementState = (direction = 0) => ({ + direction, + bodyVelocity: [0, 0], + leftFootNormal: [0, 1], + rightFootNormal: [0, 1], + groundPlanetId: null, + secondsSinceOnSurface: 1, +}); + describe('local prediction reconciliation', () => { it('reproduces the authoritative pose exactly when all input is acknowledged', () => { - let clock = 1000; - setPredictorClockForTesting(() => clock); + setFrameTimeMs(1000); const predictor = new LocalCharacterPredictor(); const auth = poseAt(500, 500); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, [0, 0], -Infinity); // server has consumed this input + predictor.acknowledge(t, movementState() as never, -Infinity); // server has consumed this input predictor.setStrength(80); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); // No clock advance → zero replay window → predicted pose == authoritative. const used = predictor.update([], 1 / 60); @@ -48,18 +64,19 @@ describe('local prediction reconciliation', () => { it('replays un-acknowledged input deterministically', () => { const drive = () => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); - clock = 1000; - predictor.acknowledge(900, [0, 0], -Infinity); // baseline ack (older than the input below) + setFrameTimeMs(1000); + predictor.acknowledge(900, movementState() as never, -Infinity); // baseline ack (older than the input below) predictor.setStrength(80); const auth = poseAt(0, 0); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); predictor.recordInput([1, 0]); // unacked rightward input at t=1000 - clock = 1100; // replay ~100 ms forward + setFrameTimeMs(1100); // replay ~100 ms forward predictor.update([], 1 / 60); return [ predictor.head.center[0], @@ -77,18 +94,21 @@ describe('local prediction reconciliation', () => { }); it('suppresses prediction while the local player is dead', () => { - let clock = 5000; - setPredictorClockForTesting(() => clock); + setFrameTimeMs(5000); const predictor = new LocalCharacterPredictor(); const auth = poseAt(200, 200); const t = predictor.recordInput([1, 0]); - predictor.acknowledge(t, [0, 0], -Infinity); + predictor.acknowledge(t, movementState() as never, -Infinity); predictor.setStrength(80); - predictor.setAuthoritative(auth.head as never, auth.leftFoot as never, auth.rightFoot as never); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); predictor.setAlive(false); // dead, awaiting respawn - clock = 5200; // input + elapsed time that would otherwise be replayed forward + setFrameTimeMs(5200); // input + elapsed time that would otherwise be replayed forward // Even with a valid authoritative pose and pending input, a dead body must // not be predicted/moved — that was the "move while dead" bug. expect(predictor.update([], 1 / 60)).toBe(false); @@ -100,22 +120,19 @@ describe('local prediction reconciliation', () => { // and the local body sat a full one-way trip behind the server. it('replays the whole un-acknowledged span, so a longer trip predicts further', () => { const driveWithUnackedSpan = (ackAgeMs: number) => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); // The key went down well before the acknowledged span and is still held — // the case that used to freeze the acknowledgement, because movement is // only transmitted when it changes. - clock = 9_000; + setFrameTimeMs(9_000); predictor.recordInput([1, 0]); - clock = 10_000; + setFrameTimeMs(10_000); const auth = poseAt(0, 0); // The server has confirmed input only up to `ackAgeMs` ago; the snapshot // carrying that acknowledgement arrives now. - predictor.acknowledge(clock - ackAgeMs, [0, 0], -Infinity); + predictor.acknowledge(10_000 - ackAgeMs, movementState() as never, -Infinity); predictor.setAuthoritative( auth.head as never, auth.leftFoot as never, @@ -134,32 +151,30 @@ describe('local prediction reconciliation', () => { // a 40 ms one; when the window was pinned to the snapshot they were equal. expect(longTrip).toBeGreaterThan(shortTrip * 2); }); + // ...but the anchor must not also inherit the AGE of whichever input happened // to be the newest one the server had when it took the snapshot. Input is sent - // once per frame, so that age is 0..1 frame depending on where the client's - // frames fell between two snapshots, and it walks a sawtooth: measured against - // a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so on. - // Left in the window, it jumped the predicted body by up to a frame of travel - // 25 times a second — the local body was choppy on a link with no latency at - // all to hide it. The server reports the age with the acknowledgement so the - // anchor lands on the snapshot instant instead. + // at a fixed cadence, so that age is 0..1 send interval depending on where the + // client's sends fell between two snapshots, and it walks a sawtooth: measured + // against a real server on localhost it ran 5, 16, 12, 8, 3, 14, 9, 1 ms and so + // on. Left in the window, it jumped the predicted body by up to a frame of + // travel 25 times a second — the local body was choppy on a link with no + // latency at all to hide it. The server reports the age with the + // acknowledgement so the anchor lands on the snapshot instant instead. it('cancels the age of the acknowledged input, so the send cadence cannot beat', () => { // Both runs describe the SAME snapshot: taken 20 ms ago, of the same pose. // They differ only in how long before it the server's newest input arrived. const snapshotAgeMs = 20; const drive = (inputAgeMs: number, serverReportsAge: boolean) => { - let clock = 0; - setPredictorClockForTesting(() => clock); - const predictor = new LocalCharacterPredictor(); predictor.setStrength(80); - clock = 9_000; + setFrameTimeMs(9_000); predictor.recordInput([1, 0]); // held down throughout - clock = 10_000; + setFrameTimeMs(10_000); predictor.acknowledge( - clock - snapshotAgeMs - inputAgeMs, - [0, 0], + 10_000 - snapshotAgeMs - inputAgeMs, + movementState() as never, -Infinity, serverReportsAge ? inputAgeMs : 0, ); @@ -179,4 +194,57 @@ describe('local prediction reconciliation', () => { // 16 ms moves the body, so the assertion above does not hold vacuously. expect(Math.abs(drive(16, false) - drive(0, false))).toBeGreaterThan(0.5); }); + + // The jitter this file exists to catch. A walking body's posture springs lag, + // so its head trails the upright position — and the facing direction is NOT + // recoverable from such a pose: the angle you get back is tens of degrees off. + // The predictor used to derive it that way rather than take the server's, so + // every replay opened by twisting the body towards the pose-implied posture, + // by an amount that depended on how many ticks the window happened to contain. + // The rendered body then advanced a different distance each frame. + it('replays the streamed facing direction, not one guessed from the pose', () => { + // Head pulled 35 units behind the upright position: the steady-state posture + // lag of a body walking to the right. atan2 on this pose gives ~0.57 rad; + // the server's own direction is 0. + const walkingPose = () => ({ + head: { center: [-35, 37], radius: HEAD_RADIUS }, + leftFoot: { center: [-33, -18], radius: FEET_RADIUS }, + rightFoot: { center: [33, -18], radius: FEET_RADIUS }, + }); + + // One snapshot interval of replay, which is as much as reconciliation ever + // asks for on a healthy link. + const predictWithDirection = (direction: number) => { + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(80); + setFrameTimeMs(0); + predictor.recordInput([1, 0]); + predictor.acknowledge(0, movementState(direction) as never, -Infinity); + const auth = walkingPose(); + predictor.setAuthoritative( + auth.head as never, + auth.leftFoot as never, + auth.rightFoot as never, + ); + + // A fresh predictor renders its first pose by snapping, so this is the raw + // prediction with no easing laid over it. + setFrameTimeMs(40); + predictor.update([], 1 / 60); + return [predictor.head.center[0], predictor.head.center[1]]; + }; + + const upright = predictWithDirection(0); + const asThePoseImplies = predictWithDirection(0.566); + const apart = Math.hypot( + upright[0] - asThePoseImplies[0], + upright[1] - asThePoseImplies[1], + ); + + // The guess moved the body by a visible amount every 40 ms — and by a + // different amount each frame, because the correction it triggers decays + // over the replay. If the streamed direction were ignored again the two + // would come out identical (this was exactly 0 before the fix). + expect(apart).toBeGreaterThan(1); + }); });