From 86e97df0dca2a83c7b8b3454dc71ee640cc97115 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Wed, 26 Aug 2026 07:47:30 +0100 Subject: [PATCH] , --- backend/src/game-server.ts | 8 +- backend/src/players/player-container.ts | 3 +- backend/src/players/player.ts | 16 +- frontend/src/main.scss | 2 - frontend/src/scripts/game.ts | 3 + .../prediction/local-character-predictor.ts | 62 ++-- frontend/src/scripts/minimap.ts | 9 +- .../scripts/objects/game-object-container.ts | 3 +- frontend/src/scripts/objects/types/camera.ts | 12 +- shared/src/helper/ease.ts | 19 ++ shared/src/physics/character-movement.ts | 24 +- test/render-smoothness.test.ts | 288 ++++++++++++++++++ 12 files changed, 414 insertions(+), 35 deletions(-) create mode 100644 test/render-smoothness.test.ts diff --git a/backend/src/game-server.ts b/backend/src/game-server.ts index 9bfac97..7c081c6 100644 --- a/backend/src/game-server.ts +++ b/backend/src/game-server.ts @@ -301,6 +301,7 @@ export class GameServer implements GameEvents { private handlePhysics() { const frameStart = process.hrtime.bigint(); + const frameStartMs = performance.now(); const delta = Number(frameStart - this.lastPhysicsBase) / 1e9; this.lastPhysicsBase = frameStart; @@ -348,8 +349,13 @@ export class GameServer implements GameEvents { this.players.step(scaledDelta); } + // Physics has only advanced to the start of this frame less whatever is still in the + // accumulator. Timing anything off the send time instead would put up to one physics step + // of noise on every interpolated position and on the client's replay anchor. + const simulatedThroughMs = frameStartMs - this.physicsAccumulator * 1000; + const propertyUpdates = new Map(); - this.players.stepCommunication(delta, (object) => { + this.players.stepCommunication(delta, simulatedThroughMs, (object) => { if (!propertyUpdates.has(object)) { propertyUpdates.set(object, object.getPropertyUpdates()); } diff --git a/backend/src/players/player-container.ts b/backend/src/players/player-container.ts index 8fc8b2f..d82497f 100644 --- a/backend/src/players/player-container.ts +++ b/backend/src/players/player-container.ts @@ -82,10 +82,11 @@ export class PlayerContainer { public stepCommunication( deltaTimeInSeconds: number, + simulatedThroughMs: number, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, ) { this._players.forEach((p) => - p.stepCommunications(deltaTimeInSeconds, propertyUpdatesOf), + p.stepCommunications(deltaTimeInSeconds, simulatedThroughMs, propertyUpdatesOf), ); } diff --git a/backend/src/players/player.ts b/backend/src/players/player.ts index c4678c7..baa9f1a 100644 --- a/backend/src/players/player.ts +++ b/backend/src/players/player.ts @@ -160,6 +160,7 @@ export class Player extends PlayerBase { public stepCommunications( deltaTime: number, + simulatedThroughMs: number, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, ) { const remoteCalls: Array = []; @@ -180,15 +181,20 @@ export class Player extends PlayerBase { this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); } + // Subtracted, not zeroed: zeroing would stretch every interval to the next physics frame. if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) { - this.timeSinceLastMessage = 0; + this.timeSinceLastMessage = Math.min( + this.timeSinceLastMessage - settings.updateMessageInterval, + settings.updateMessageInterval, + ); this.queueAnnouncement(); - this.queueSnapshot(propertyUpdatesOf); + this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf); this.sendQueuedCommandsToClient(); } } private queueSnapshot( + simulatedThroughMs: number, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, ) { const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2); @@ -227,7 +233,7 @@ export class Player extends PlayerBase { } } this.queueCommandSend( - new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), + new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000), ); if (this.character) { @@ -236,8 +242,10 @@ export class Player extends PlayerBase { this.lastInputClientTimeMs, this.character.movementSnapshot, this.lastLeapClientTimeMs, + // Aged against the instant the pose is from, not the send time: the difference is + // the un-simulated remainder, and it would land straight in the client's replay. this.lastInputReceiptMs > 0 - ? Math.max(0, performance.now() - this.lastInputReceiptMs) + ? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs) : 0, ), ); diff --git a/frontend/src/main.scss b/frontend/src/main.scss index 0009666..021ab71 100644 --- a/frontend/src/main.scss +++ b/frontend/src/main.scss @@ -127,7 +127,6 @@ body { .player-tag { border-radius: 1000px; - transition: transform 200ms; &.blue { color: $bright-blue; @@ -255,7 +254,6 @@ body { // Off-screen pointer to the keystone "Heart", tinted by its current owner. .keystone-arrow { - transition: transform 150ms; opacity: 0.9; @include square($large-icon); diff --git a/frontend/src/scripts/game.ts b/frontend/src/scripts/game.ts index 834757d..d4bc384 100644 --- a/frontend/src/scripts/game.ts +++ b/frontend/src/scripts/game.ts @@ -347,6 +347,9 @@ export class Game extends CommandReceiver { } private draw() { + // Before any style writes this frame, so the read cannot force a synchronous layout. + this.minimap.measure(); + if (this.lastGameState) { this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); } diff --git a/frontend/src/scripts/helper/prediction/local-character-predictor.ts b/frontend/src/scripts/helper/prediction/local-character-predictor.ts index f437042..6e52ae5 100644 --- a/frontend/src/scripts/helper/prediction/local-character-predictor.ts +++ b/frontend/src/scripts/helper/prediction/local-character-predictor.ts @@ -5,8 +5,7 @@ import { CharacterMovementState, PhysicsBody, settings, - clamp, - easeVec2, + followVec2, stepCharacterMovement, applyLeapImpulse, tickPlanetDetachment, @@ -24,10 +23,12 @@ let frameTimeMs = 0; export const setFrameTimeMs = (timeMs: number): void => { frameTimeMs = timeMs; }; -export const clientTimeMs = (): number => Math.round(frameTimeMs); +export const clientTimeMs = (): number => frameTimeMs; const maxReplayMs = 400; +// Only a correction has to fade; the character's own motion is carried straight through, so this +// buys smoothing without costing responsiveness. const smoothSeconds = 0.06; const snapDistance = 250; @@ -58,6 +59,7 @@ export class LocalCharacterPredictor { private renderHead = new Circle(vec2.create(), headRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderRightFoot = new Circle(vec2.create(), feetRadius); + private previousTargets = [vec2.create(), vec2.create(), vec2.create()]; private hasRender = false; public get head(): Circle { @@ -152,7 +154,7 @@ export class LocalCharacterPredictor { this.snapRenderTo(predicted); this.hasRender = true; } else { - this.easeRenderTo(predicted, frameSeconds); + this.followRenderTo(predicted, frameSeconds); } return true; } @@ -161,10 +163,10 @@ export class LocalCharacterPredictor { const auth = this.authoritative!; const movement = this.movement!; const now = clientTimeMs(); - const startMs = clamp(this.replayAnchorMs!, now - maxReplayMs, now); + const startMs = Math.max(this.replayAnchorMs!, now - maxReplayMs); const windowMs = Math.max(0, now - startMs); const steps = Math.floor(windowMs / stepMs); - const remainderSeconds = (windowMs - steps * stepMs) / 1000; + const partialStep = (windowMs - steps * stepMs) / stepMs; const state: CharacterMovementState = { head: makeBody(auth.head.center, auth.head.radius, upwards), @@ -191,9 +193,10 @@ export class LocalCharacterPredictor { let t = startMs; for (let i = 0; i < steps; i++) { const input = this.inputHistory.directionAt(t); + // Planets first: the server's container holds them ahead of every character. + this.world.advance(stepSeconds); tickPlanetDetachment(state, stepSeconds); stepCharacterMovement(state, this.world, input, stepSeconds); - this.world.advance(stepSeconds); for (const leapMs of this.leapHistory) { if ( @@ -213,21 +216,32 @@ export class LocalCharacterPredictor { t += stepMs; } - if (remainderSeconds > 0) { - tickPlanetDetachment(state, remainderSeconds); + // A shorter final step would not integrate like the server's fixed one, so take a whole + // step and read the pose part-way along it instead. + if (partialStep > 0) { + const from = [state.head, state.leftFoot, state.rightFoot].map((b) => + vec2.clone(b.center), + ); + this.world.advance(stepSeconds); + tickPlanetDetachment(state, stepSeconds); stepCharacterMovement( state, this.world, - this.inputHistory.directionAt(now), - remainderSeconds, + this.inputHistory.directionAt(t), + stepSeconds, + ); + [state.head, state.leftFoot, state.rightFoot].forEach((body, i) => + vec2.lerp(body.center, from[i], body.center, partialStep), ); - this.world.advance(remainderSeconds); } return state; } private snapRenderTo(state: CharacterMovementState): void { + this.previousTargets = [state.head, state.leftFoot, state.rightFoot].map((b) => + vec2.clone(b.center), + ); this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius); this.renderLeftFoot = new Circle( vec2.clone(state.leftFoot.center), @@ -239,14 +253,26 @@ export class LocalCharacterPredictor { ); } - private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { - this.easePart(this.renderHead, state.head, frameSeconds); - this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds); - this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds); + private followRenderTo(state: CharacterMovementState, frameSeconds: number): void { + this.followPart(this.renderHead, state.head, 0, frameSeconds); + this.followPart(this.renderLeftFoot, state.leftFoot, 1, frameSeconds); + this.followPart(this.renderRightFoot, state.rightFoot, 2, frameSeconds); } - private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void { - easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance); + private followPart( + render: Circle, + target: PhysicsBody, + index: number, + frameSeconds: number, + ): void { + followVec2( + render.center, + target.center, + this.previousTargets[index], + frameSeconds, + smoothSeconds, + snapDistance, + ); render.radius = target.radius; } } diff --git a/frontend/src/scripts/minimap.ts b/frontend/src/scripts/minimap.ts index 7074738..a9187b2 100644 --- a/frontend/src/scripts/minimap.ts +++ b/frontend/src/scripts/minimap.ts @@ -8,6 +8,7 @@ export class Minimap { private readonly colors: Record; private readonly smoothed = new Map(); private bufferSize = 0; + private size = 0; constructor() { this.element.className = 'minimap'; @@ -23,8 +24,14 @@ export class Minimap { }; } + // Reading clientWidth after this frame's style writes would force a synchronous layout of + // the whole overlay inside the animation frame; the size only changes when the window does. + public measure() { + this.size = this.element.clientWidth; + } + public update(localPosition: vec2 | undefined, players: Array) { - const size = this.element.clientWidth; + const size = this.size; if (size === 0) { return; } diff --git a/frontend/src/scripts/objects/game-object-container.ts b/frontend/src/scripts/objects/game-object-container.ts index 69f03db..be522da 100644 --- a/frontend/src/scripts/objects/game-object-container.ts +++ b/frontend/src/scripts/objects/game-object-container.ts @@ -119,12 +119,13 @@ export class GameObjectContainer extends CommandReceiver { } public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { + // First: everything below projects world coordinates through this frame's view area. + this.camera.draw(renderer); this.objects.forEach((o) => { if (this.isVisible(o.id)) { o.render(renderer, overlay, shouldChangeLayout); } }); - this.camera.draw(renderer); } private isVisible(id: Id): boolean { diff --git a/frontend/src/scripts/objects/types/camera.ts b/frontend/src/scripts/objects/types/camera.ts index b39e10e..ea8ee4d 100644 --- a/frontend/src/scripts/objects/types/camera.ts +++ b/frontend/src/scripts/objects/types/camera.ts @@ -1,6 +1,6 @@ import { vec2 } from 'gl-matrix'; import { Renderer } from 'sdf-2d'; -import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared'; +import { calculateViewArea, followVec2, mixRgb, settings } from 'shared'; import { Game } from '../../game'; import { ScreenShake } from '../../screen-shake'; @@ -9,12 +9,20 @@ const snapDistance = 1500; export class Camera { public readonly center: vec2 = vec2.create(); + private readonly previousTarget = vec2.create(); private aspectRatio?: number; constructor(private readonly game: Game) {} public follow(target: vec2, deltaTimeInSeconds: number) { - easeVec2(this.center, target, deltaTimeInSeconds, followSeconds, snapDistance); + followVec2( + this.center, + target, + this.previousTarget, + deltaTimeInSeconds, + followSeconds, + snapDistance, + ); } public draw(renderer: Renderer) { diff --git a/shared/src/helper/ease.ts b/shared/src/helper/ease.ts index 13f9f78..789ddc0 100644 --- a/shared/src/helper/ease.ts +++ b/shared/src/helper/ease.ts @@ -13,3 +13,22 @@ export const easeVec2 = ( vec2.distance(out, target) > snapDistance ? vec2.copy(out, target) : vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); + +// Follows a *moving* target. `easeVec2` alone lags one by an amount that depends on the frame +// duration, so an uneven frame — or a dropped one — changes the lag and the follower appears to +// speed up or stall. Carrying the target's own displacement first leaves only the residual to +// decay: a target at a steady speed is tracked exactly, whatever the frame times do, and a +// correction still fades out over `seconds`. +export const followVec2 = ( + out: vec2, + target: vec2, + previousTarget: vec2, + deltaTimeInSeconds: number, + seconds: number, + snapDistance: number, +): vec2 => { + out[0] += target[0] - previousTarget[0]; + out[1] += target[1] - previousTarget[1]; + vec2.copy(previousTarget, target); + return easeVec2(out, target, deltaTimeInSeconds, seconds, snapDistance); +}; diff --git a/shared/src/physics/character-movement.ts b/shared/src/physics/character-movement.ts index 1098478..81c2b08 100644 --- a/shared/src/physics/character-movement.ts +++ b/shared/src/physics/character-movement.ts @@ -2,6 +2,7 @@ import { vec2 } from 'gl-matrix'; import { settings } from '../settings'; import { GroundSurface, PhysicsBody } from './sdf'; import { interpolateAngles } from './interpolate-angles'; +import { smoothing } from '../helper/ease'; export const headRadius = 50; export const feetRadius = 20; @@ -69,11 +70,20 @@ export const characterCenter = ( return vec2.scale(center, center, 1 / 3); }; -const setDirection = (state: CharacterMovementState, direction: vec2) => { +// Tuned so one physics step still turns the same 20% it always did, but a replay that splits +// or merges steps now lands on the same facing instead of one that depends on the step count. +const directionSmoothingSeconds = + settings.targetPhysicsDeltaTimeInSeconds / Math.log(1 / (1 - 0.2)); + +const setDirection = ( + state: CharacterMovementState, + direction: vec2, + deltaTimeInSeconds: number, +) => { state.direction = interpolateAngles( state.direction, Math.atan2(direction[1], direction[0]) + Math.PI / 2, - 0.2, + smoothing(deltaTimeInSeconds, directionSmoothingSeconds), ); }; @@ -255,7 +265,7 @@ export const stepCharacterMovement = ( vec2.zero(state.rightFoot.velocity); const movementForce = vec2.scale( - inputDirection, + vec2.create(), inputDirection, settings.maxAcceleration, ); @@ -272,7 +282,11 @@ export const stepCharacterMovement = ( applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds); const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); - setDirection(state, vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce); + setDirection( + state, + vec2.length(sumForce) === 0 ? vec2.fromValues(0, -1) : sumForce, + deltaTimeInSeconds, + ); } else { carryWithRotatingPlanet(state, deltaTimeInSeconds); @@ -310,7 +324,7 @@ export const stepCharacterMovement = ( if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { state.currentPlanet = undefined; } - setDirection(state, gravity); + setDirection(state, gravity, deltaTimeInSeconds); } keepPosture(state); diff --git a/test/render-smoothness.test.ts b/test/render-smoothness.test.ts new file mode 100644 index 0000000..299a4f4 --- /dev/null +++ b/test/render-smoothness.test.ts @@ -0,0 +1,288 @@ +import { describe, it, expect } from 'vitest'; +import { + LocalCharacterPredictor, + setFrameTimeMs, +} from '../frontend/src/scripts/helper/prediction/local-character-predictor'; +import { ClientCharacterWorld } from '../frontend/src/scripts/helper/prediction/client-character-world'; +import { + stepCharacterMovement, + tickPlanetDetachment, + headRadius, + feetRadius, +} from '../shared/src/physics/character-movement'; +import { followVec2 } from '../shared/src/helper/ease'; +import { settings } from '../shared/src/settings'; + +const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds; +const snapshotMs = settings.updateMessageInterval * 1000; +const heartbeatMs = settings.clientSendInterval * 1000; +const planetRadius = 800; + +// The client's frame clock is rebased to its first frame (sdf-2d passes currentTime - startTime), +// so it shares no origin with the server's clock. Anything that only works when the two happen to +// agree is not a fix. +const clientClockOffsetMs = 987_654.321; + +const vertices = Array.from({ length: settings.planetEdgeCount }, (_, i) => { + const angle = (i / settings.planetEdgeCount) * -Math.PI * 2; + return [planetRadius * Math.cos(angle), planetRadius * Math.sin(angle)]; +}); + +const planetAt = (rotation: number) => + ({ id: 1, vertices, snapshotRotation: rotation, snapshotRotationSpeed: 0 }) as never; + +const body = (center: Array, radius: number) => ({ + center: center.slice(), + radius, + velocity: [0, 0], + lastNormal: [0, 1], + restitution: 0, +}); + +interface Conditions { + fps: number; + /** One way. The round trip is twice this. */ + wireMs: number; + /** Client CPU spent between the frame timestamp and the socket emit at the end of the loop. */ + emitLagMs: number; + /** Peak random variation in frame duration, as a fraction of the nominal frame. */ + frameJitter: number; + /** Probability that a frame is missed entirely and the next one is twice as long. */ + dropRate: number; + /** Server work that can delay a socket callback past the packet's arrival. */ + serverBusyMs: number; +} + +interface Result { + /** Peak-to-peak variation in apparent speed, over its mean. Zero means perfectly even motion. */ + speedSpread: number; + /** Peak-to-peak wobble of the character's screen position about its own mean, in world units. */ + screenWobble: number; + /** How far the rendered character trails the true server pose, in world units. */ + lagUnits: number; +} + +// Runs a real server tick loop, a real wire, and a real render loop against the predictor, and +// reports how steady the character's *on-screen* travel is. On-screen, not in world space: the +// camera follows the character, so what the player sees is the residual between the two. +const measure = ({ + fps, + wireMs, + emitLagMs, + frameJitter, + dropRate, + serverBusyMs, +}: Conditions): Result => { + const world = new ClientCharacterWorld(); + world.sync([planetAt(0)]); + + const server: any = { + head: body([0, planetRadius + 90], headRadius), + leftFoot: body([-33, planetRadius + 35], feetRadius), + rightFoot: body([33, planetRadius + 35], feetRadius), + direction: 0, + currentPlanet: world.surfaceById(1), + secondsSinceOnSurface: 0, + bodyVelocity: [0, 0], + }; + + const input = [1, 0]; + const predictor = new LocalCharacterPredictor(); + predictor.setStrength(settings.playerMaxStrength); + + // Deterministic frame-time noise: a real display never delivers a perfectly even cadence. + let seed = 12345; + const random = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff); + + const nominalFrameMs = 1000 / fps; + const toServer: Array<{ atMs: number; clientStampMs: number }> = []; + const toClient: Array<{ atMs: number; ackAgeMs: number; clientStampMs: number; pose: any }> = []; + const samples: Array<{ + screen: Array; + world: Array; + atMs: number; + error: number; + }> = []; + + // The client's own view of the character, and the camera that chases it. + const camera = [0, planetRadius + 90]; + const cameraPreviousTarget = [0, planetRadius + 90]; + + let simMs = 0; + let nextSnapshotMs = 0; + let nextFrameAtMs = 0; + let lastHeartbeatClientMs = -Infinity; + let lastInputClientTimeMs = 0; + let lastInputReceiptMs = 0; + let hasPressed = false; + + + while (simMs < 8000) { + tickPlanetDetachment(server, stepSeconds); + stepCharacterMovement(server, world, input.slice() as never, stepSeconds); + simMs += stepSeconds * 1000; + + while (toServer.length > 0 && toServer[0].atMs <= simMs) { + const packet = toServer.shift()!; + if (packet.clientStampMs > lastInputClientTimeMs) { + lastInputClientTimeMs = packet.clientStampMs; + // Socket callbacks run between physics frames, so this is the true arrival instant, + // not the frame boundary — plus however long the frame in progress still had to run. + lastInputReceiptMs = packet.atMs + serverBusyMs; + } + } + + if (simMs >= nextSnapshotMs) { + nextSnapshotMs += snapshotMs; + toClient.push({ + atMs: simMs + wireMs, + clientStampMs: lastInputClientTimeMs, + ackAgeMs: lastInputReceiptMs > 0 ? Math.max(0, simMs - lastInputReceiptMs) : 0, + pose: { + head: server.head.center.slice(), + leftFoot: server.leftFoot.center.slice(), + rightFoot: server.rightFoot.center.slice(), + movement: { + direction: server.direction, + bodyVelocity: server.bodyVelocity.slice(), + leftFootNormal: server.leftFoot.lastNormal.slice(), + rightFootNormal: server.rightFoot.lastNormal.slice(), + groundPlanetId: 1, + secondsSinceOnSurface: server.secondsSinceOnSurface, + }, + }, + }); + } + + while (toClient.length > 0 && toClient[0].atMs <= simMs) { + const packet = toClient.shift()!; + predictor.acknowledge( + packet.clientStampMs, + packet.pose.movement, + -Infinity, + packet.ackAgeMs, + ); + predictor.setAuthoritative( + { center: packet.pose.head, radius: headRadius } as never, + { center: packet.pose.leftFoot, radius: feetRadius } as never, + { center: packet.pose.rightFoot, radius: feetRadius } as never, + ); + } + + while (nextFrameAtMs <= simMs) { + const frameClientMs = nextFrameAtMs + clientClockOffsetMs; + setFrameTimeMs(frameClientMs); + + // Movement input is edge-triggered, exactly as KeyboardListener sends it: recorded once + // when the key goes down, then held by both sides. + if (!hasPressed) { + hasPressed = true; + predictor.recordInput(input as never); + } + + const dropped = random() < dropRate; + const jitter = 1 + (random() * 2 - 1) * frameJitter; + const frameMs = nominalFrameMs * jitter * (dropped ? 2 : 1); + + if (!dropped) { + predictor.update([planetAt(0)], frameMs / 1000); + const head = predictor.head.center; + followVec2( + camera as never, + head, + cameraPreviousTarget as never, + frameMs / 1000, + 0.08, + 1500, + ); + samples.push({ + screen: [head[0] - camera[0], head[1] - camera[1]], + world: [head[0], head[1]], + atMs: frameClientMs, + error: Math.hypot(head[0] - server.head.center[0], head[1] - server.head.center[1]), + }); + } + + // Stamped at the top of the frame, emitted at the bottom. + if (frameClientMs - lastHeartbeatClientMs >= heartbeatMs - 1) { + lastHeartbeatClientMs = frameClientMs; + toServer.push({ + atMs: nextFrameAtMs + emitLagMs + wireMs, + clientStampMs: frameClientMs, + }); + } + + nextFrameAtMs += frameMs; + } + } + + const tail = samples.slice(Math.floor(samples.length / 2)); + + // Apparent speed: world travel divided by how long the frame was on screen. A longer frame + // should show proportionally more travel; anything else reads as a lurch. + const speeds = tail + .slice(1) + .map( + (s, i) => + Math.hypot(s.world[0] - tail[i].world[0], s.world[1] - tail[i].world[1]) / + (s.atMs - tail[i].atMs), + ); + const meanSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length; + + // Screen position: the camera settles to a constant lag, so anything left is visible wobble. + const centre = [0, 1].map((axis) => { + const values = tail.map((s) => s.screen[axis]); + return values.reduce((a, b) => a + b, 0) / values.length; + }); + const wobble = Math.max( + ...tail.map((s) => Math.hypot(s.screen[0] - centre[0], s.screen[1] - centre[1])), + ); + + if ((globalThis as any).__dump) { + console.log('--- apparent speed (u/ms) ---'); + speeds.slice(0, 40).forEach((v, i) => console.log(`${i} ${v.toFixed(4)}`)); + } + return { + speedSpread: (Math.max(...speeds) - Math.min(...speeds)) / meanSpeed, + screenWobble: wobble, + lagUnits: tail.reduce((sum, s) => sum + s.error, 0) / tail.length, + }; +}; + +const CONDITIONS: Array<[string, Conditions]> = [ + ['localhost, clean frames', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], + ['localhost, 144 Hz', { fps: 144, wireMs: 0.2, emitLagMs: 2, frameJitter: 0, dropRate: 0, serverBusyMs: 0 }], + ['localhost, frame jitter', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.15, dropRate: 0, serverBusyMs: 0 }], + ['localhost, 2% dropped', { fps: 60, wireMs: 0.2, emitLagMs: 2, frameJitter: 0.05, dropRate: 0.02, serverBusyMs: 0 }], + ['localhost, variable CPU', { fps: 60, wireMs: 0.2, emitLagMs: 8, frameJitter: 0.15, dropRate: 0.02, serverBusyMs: 0 }], + ['30 ms link', { fps: 60, wireMs: 15, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], + ['120 ms link', { fps: 60, wireMs: 60, emitLagMs: 2, frameJitter: 0.05, dropRate: 0, serverBusyMs: 0 }], +]; + +describe('local character render smoothness', () => { + it('keeps on-screen travel even under realistic clocks and frame pacing', () => { + ;(globalThis as any).__dump = true + measure(CONDITIONS[0][1]) + ;(globalThis as any).__dump = false + const results: Array<[string, Result]> = CONDITIONS.map(([label, c]) => [label, measure(c)]); + + for (const [label, r] of results) { + console.log( + `${label.padEnd(26)} speed=${(r.speedSpread * 100).toFixed(1).padStart(6)}% ` + + `wobble=${r.screenWobble.toFixed(2).padStart(6)}u ` + + `lag=${r.lagUnits.toFixed(1).padStart(5)}u `, + ); + } + + for (const [label, r] of results) { + // Even travel: what the eye reads as smooth motion. + expect(r.speedSpread, label).toBeLessThan(0.05); + // No sub-pixel swimming against the camera, which follows the same character. + expect(r.screenWobble, label).toBeLessThan(1); + } + + // Prediction exists to cancel latency; a follower that lags undoes that on every link. + const localhost = results.find(([label]) => label === 'localhost, clean frames')![1]; + expect(localhost.lagUnits).toBeLessThan(5); + }); +});