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() { private handlePhysics() {
const frameStart = process.hrtime.bigint(); const frameStart = process.hrtime.bigint();
const frameStartMs = performance.now();
const delta = Number(frameStart - this.lastPhysicsBase) / 1e9; const delta = Number(frameStart - this.lastPhysicsBase) / 1e9;
this.lastPhysicsBase = frameStart; this.lastPhysicsBase = frameStart;
@ -348,8 +349,13 @@ export class GameServer implements GameEvents {
this.players.step(scaledDelta); 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>(); const propertyUpdates = new Map<GameObject, PropertyUpdatesForObject | undefined>();
this.players.stepCommunication(delta, (object) => { this.players.stepCommunication(delta, simulatedThroughMs, (object) => {
if (!propertyUpdates.has(object)) { if (!propertyUpdates.has(object)) {
propertyUpdates.set(object, object.getPropertyUpdates()); propertyUpdates.set(object, object.getPropertyUpdates());
} }

View file

@ -82,10 +82,11 @@ export class PlayerContainer {
public stepCommunication( public stepCommunication(
deltaTimeInSeconds: number, deltaTimeInSeconds: number,
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) { ) {
this._players.forEach((p) => 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( public stepCommunications(
deltaTime: number, deltaTime: number,
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) { ) {
const remoteCalls: Array<RemoteCallsForObject> = []; const remoteCalls: Array<RemoteCallsForObject> = [];
@ -180,15 +181,20 @@ export class Player extends PlayerBase {
this.socket.emit(TransportEvents.Ping, this.pendingPingNonce); 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) { if ((this.timeSinceLastMessage += deltaTime) > settings.updateMessageInterval) {
this.timeSinceLastMessage = 0; this.timeSinceLastMessage = Math.min(
this.timeSinceLastMessage - settings.updateMessageInterval,
settings.updateMessageInterval,
);
this.queueAnnouncement(); this.queueAnnouncement();
this.queueSnapshot(propertyUpdatesOf); this.queueSnapshot(simulatedThroughMs, propertyUpdatesOf);
this.sendQueuedCommandsToClient(); this.sendQueuedCommandsToClient();
} }
} }
private queueSnapshot( private queueSnapshot(
simulatedThroughMs: number,
propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined, propertyUpdatesOf: (object: GameObject) => PropertyUpdatesForObject | undefined,
) { ) {
const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2); const { topLeft, size } = calculateViewArea(this.center, this.aspectRatio, 1.2);
@ -227,7 +233,7 @@ export class Player extends PlayerBase {
} }
} }
this.queueCommandSend( this.queueCommandSend(
new PropertyUpdatesForObjects(propertyUpdates, performance.now() / 1000), new PropertyUpdatesForObjects(propertyUpdates, simulatedThroughMs / 1000),
); );
if (this.character) { if (this.character) {
@ -236,8 +242,10 @@ export class Player extends PlayerBase {
this.lastInputClientTimeMs, this.lastInputClientTimeMs,
this.character.movementSnapshot, this.character.movementSnapshot,
this.lastLeapClientTimeMs, 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 this.lastInputReceiptMs > 0
? Math.max(0, performance.now() - this.lastInputReceiptMs) ? Math.max(0, simulatedThroughMs - this.lastInputReceiptMs)
: 0, : 0,
), ),
); );

View file

@ -127,7 +127,6 @@ body {
.player-tag { .player-tag {
border-radius: 1000px; border-radius: 1000px;
transition: transform 200ms;
&.blue { &.blue {
color: $bright-blue; color: $bright-blue;
@ -255,7 +254,6 @@ body {
// Off-screen pointer to the keystone "Heart", tinted by its current owner. // Off-screen pointer to the keystone "Heart", tinted by its current owner.
.keystone-arrow { .keystone-arrow {
transition: transform 150ms;
opacity: 0.9; opacity: 0.9;
@include square($large-icon); @include square($large-icon);

View file

@ -347,6 +347,9 @@ export class Game extends CommandReceiver {
} }
private draw() { private draw() {
// Before any style writes this frame, so the read cannot force a synchronous layout.
this.minimap.measure();
if (this.lastGameState) { if (this.lastGameState) {
this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team); this.scoreboard.update(this.lastGameState, this.gameObjects.localPlayer?.team);
} }

View file

@ -5,8 +5,7 @@ import {
CharacterMovementState, CharacterMovementState,
PhysicsBody, PhysicsBody,
settings, settings,
clamp, followVec2,
easeVec2,
stepCharacterMovement, stepCharacterMovement,
applyLeapImpulse, applyLeapImpulse,
tickPlanetDetachment, tickPlanetDetachment,
@ -24,10 +23,12 @@ let frameTimeMs = 0;
export const setFrameTimeMs = (timeMs: number): void => { export const setFrameTimeMs = (timeMs: number): void => {
frameTimeMs = timeMs; frameTimeMs = timeMs;
}; };
export const clientTimeMs = (): number => Math.round(frameTimeMs); export const clientTimeMs = (): number => frameTimeMs;
const maxReplayMs = 400; 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 smoothSeconds = 0.06;
const snapDistance = 250; const snapDistance = 250;
@ -58,6 +59,7 @@ export class LocalCharacterPredictor {
private renderHead = new Circle(vec2.create(), headRadius); private renderHead = new Circle(vec2.create(), headRadius);
private renderLeftFoot = new Circle(vec2.create(), feetRadius); private renderLeftFoot = new Circle(vec2.create(), feetRadius);
private renderRightFoot = new Circle(vec2.create(), feetRadius); private renderRightFoot = new Circle(vec2.create(), feetRadius);
private previousTargets = [vec2.create(), vec2.create(), vec2.create()];
private hasRender = false; private hasRender = false;
public get head(): Circle { public get head(): Circle {
@ -152,7 +154,7 @@ export class LocalCharacterPredictor {
this.snapRenderTo(predicted); this.snapRenderTo(predicted);
this.hasRender = true; this.hasRender = true;
} else { } else {
this.easeRenderTo(predicted, frameSeconds); this.followRenderTo(predicted, frameSeconds);
} }
return true; return true;
} }
@ -161,10 +163,10 @@ export class LocalCharacterPredictor {
const auth = this.authoritative!; const auth = this.authoritative!;
const movement = this.movement!; const movement = this.movement!;
const now = clientTimeMs(); 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 windowMs = Math.max(0, now - startMs);
const steps = Math.floor(windowMs / stepMs); const steps = Math.floor(windowMs / stepMs);
const remainderSeconds = (windowMs - steps * stepMs) / 1000; const partialStep = (windowMs - steps * stepMs) / stepMs;
const state: CharacterMovementState = { const state: CharacterMovementState = {
head: makeBody(auth.head.center, auth.head.radius, upwards), head: makeBody(auth.head.center, auth.head.radius, upwards),
@ -191,9 +193,10 @@ export class LocalCharacterPredictor {
let t = startMs; let t = startMs;
for (let i = 0; i < steps; i++) { for (let i = 0; i < steps; i++) {
const input = this.inputHistory.directionAt(t); 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); tickPlanetDetachment(state, stepSeconds);
stepCharacterMovement(state, this.world, input, stepSeconds); stepCharacterMovement(state, this.world, input, stepSeconds);
this.world.advance(stepSeconds);
for (const leapMs of this.leapHistory) { for (const leapMs of this.leapHistory) {
if ( if (
@ -213,21 +216,32 @@ export class LocalCharacterPredictor {
t += stepMs; t += stepMs;
} }
if (remainderSeconds > 0) { // A shorter final step would not integrate like the server's fixed one, so take a whole
tickPlanetDetachment(state, remainderSeconds); // 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( stepCharacterMovement(
state, state,
this.world, this.world,
this.inputHistory.directionAt(now), this.inputHistory.directionAt(t),
remainderSeconds, 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; return state;
} }
private snapRenderTo(state: CharacterMovementState): void { 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.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
this.renderLeftFoot = new Circle( this.renderLeftFoot = new Circle(
vec2.clone(state.leftFoot.center), vec2.clone(state.leftFoot.center),
@ -239,14 +253,26 @@ export class LocalCharacterPredictor {
); );
} }
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void { private followRenderTo(state: CharacterMovementState, frameSeconds: number): void {
this.easePart(this.renderHead, state.head, frameSeconds); this.followPart(this.renderHead, state.head, 0, frameSeconds);
this.easePart(this.renderLeftFoot, state.leftFoot, frameSeconds); this.followPart(this.renderLeftFoot, state.leftFoot, 1, frameSeconds);
this.easePart(this.renderRightFoot, state.rightFoot, frameSeconds); this.followPart(this.renderRightFoot, state.rightFoot, 2, frameSeconds);
} }
private easePart(render: Circle, target: PhysicsBody, frameSeconds: number): void { private followPart(
easeVec2(render.center, target.center, frameSeconds, smoothSeconds, snapDistance); render: Circle,
target: PhysicsBody,
index: number,
frameSeconds: number,
): void {
followVec2(
render.center,
target.center,
this.previousTargets[index],
frameSeconds,
smoothSeconds,
snapDistance,
);
render.radius = target.radius; render.radius = target.radius;
} }
} }

View file

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

View file

@ -119,12 +119,13 @@ export class GameObjectContainer extends CommandReceiver {
} }
public render(renderer: Renderer, overlay: HTMLElement, shouldChangeLayout: boolean) { 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) => { this.objects.forEach((o) => {
if (this.isVisible(o.id)) { if (this.isVisible(o.id)) {
o.render(renderer, overlay, shouldChangeLayout); o.render(renderer, overlay, shouldChangeLayout);
} }
}); });
this.camera.draw(renderer);
} }
private isVisible(id: Id): boolean { private isVisible(id: Id): boolean {

View file

@ -1,6 +1,6 @@
import { vec2 } from 'gl-matrix'; import { vec2 } from 'gl-matrix';
import { Renderer } from 'sdf-2d'; import { Renderer } from 'sdf-2d';
import { calculateViewArea, easeVec2, mixRgb, settings } from 'shared'; import { calculateViewArea, followVec2, mixRgb, settings } from 'shared';
import { Game } from '../../game'; import { Game } from '../../game';
import { ScreenShake } from '../../screen-shake'; import { ScreenShake } from '../../screen-shake';
@ -9,12 +9,20 @@ const snapDistance = 1500;
export class Camera { export class Camera {
public readonly center: vec2 = vec2.create(); public readonly center: vec2 = vec2.create();
private readonly previousTarget = vec2.create();
private aspectRatio?: number; private aspectRatio?: number;
constructor(private readonly game: Game) {} constructor(private readonly game: Game) {}
public follow(target: vec2, deltaTimeInSeconds: number) { 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) { public draw(renderer: Renderer) {

View file

@ -13,3 +13,22 @@ export const easeVec2 = (
vec2.distance(out, target) > snapDistance vec2.distance(out, target) > snapDistance
? vec2.copy(out, target) ? vec2.copy(out, target)
: vec2.lerp(out, out, target, smoothing(deltaTimeInSeconds, seconds)); : 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 { settings } from '../settings';
import { GroundSurface, PhysicsBody } from './sdf'; import { GroundSurface, PhysicsBody } from './sdf';
import { interpolateAngles } from './interpolate-angles'; import { interpolateAngles } from './interpolate-angles';
import { smoothing } from '../helper/ease';
export const headRadius = 50; export const headRadius = 50;
export const feetRadius = 20; export const feetRadius = 20;
@ -69,11 +70,20 @@ export const characterCenter = (
return vec2.scale(center, center, 1 / 3); 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 = interpolateAngles(
state.direction, state.direction,
Math.atan2(direction[1], direction[0]) + Math.PI / 2, 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); vec2.zero(state.rightFoot.velocity);
const movementForce = vec2.scale( const movementForce = vec2.scale(
inputDirection, vec2.create(),
inputDirection, inputDirection,
settings.maxAcceleration, settings.maxAcceleration,
); );
@ -272,7 +282,11 @@ export const stepCharacterMovement = (
applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds); applyForce(state.rightFoot, rightFootGravity, deltaTimeInSeconds);
const sumForce = vec2.subtract(vec2.create(), leftFootGravity, movementForce); 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 { } else {
carryWithRotatingPlanet(state, deltaTimeInSeconds); carryWithRotatingPlanet(state, deltaTimeInSeconds);
@ -310,7 +324,7 @@ export const stepCharacterMovement = (
if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) { if (vec2.length(gravity) <= settings.planetDetachmentForceThreshold) {
state.currentPlanet = undefined; state.currentPlanet = undefined;
} }
setDirection(state, gravity); setDirection(state, gravity, deltaTimeInSeconds);
} }
keepPosture(state); 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);
});
});