Improve netcode and simplify #4

Merged
andras merged 15 commits from fix/audit-findings into main 2026-09-15 07:57:58 +01:00
12 changed files with 414 additions and 35 deletions
Showing only changes of commit 86e97df0dc - Show all commits
Andras Schmelczer 2026-08-26 07:47:30 +01:00

View file

@ -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<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(delta, (object) => {
this.players.stepCommunication(delta, simulatedThroughMs, (object) => {
if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates());
}

View file

@ -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),
);
}

View file

@ -160,6 +160,7 @@ export class Player extends PlayerBase {
public stepCommunications(
deltaTime: number,
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) {
const remoteCalls: Array<RemoteCallsForObject> = [];
@ -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,
),
);

View file

@ -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);

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -8,6 +8,7 @@ export class Minimap {
private readonly colors: Record<CharacterTeam, string>;
private readonly smoothed = new Map<Id, vec2>();
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<MinimapPlayer>) {
const size = this.element.clientWidth;
const size = this.size;
if (size === 0) {
return;
}

View file

@ -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 {

View file

@ -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) {

View file

@ -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);
};

View file

@ -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);

View file

@ -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<number>, 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<number>;
world: Array<number>;
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);
});
});