This commit is contained in:
Andras Schmelczer 2026-08-22 18:35:53 +01:00
commit 5017ff168a
10 changed files with 1004 additions and 2007 deletions

View file

@ -1,7 +0,0 @@
export const hide = (e: HTMLElement, useDisplay = false) => {
if (useDisplay) {
e.style.display = 'none';
} else {
e.style.visibility = 'hidden';
}
};

View file

@ -1,22 +0,0 @@
import { Circle } from 'shared';
import { LinearInterpolator } from './linear-interpolator';
import { Vec2Interpolator } from './vec2-interpolator';
export class CircleInterpolator {
private center: Vec2Interpolator;
private radius: LinearInterpolator;
constructor(currentValue: Circle) {
this.center = new Vec2Interpolator(currentValue.center);
this.radius = new LinearInterpolator(currentValue.radius);
}
public addFrame(value: Circle, rateOfChange: Circle) {
this.center.addFrame(value.center, rateOfChange.center);
this.radius.addFrame(value.radius, rateOfChange.radius);
}
public getValue(deltaTime: number): Circle {
return new Circle(this.center.getValue(deltaTime), this.radius.getValue(deltaTime));
}
}

View file

@ -1,4 +1,4 @@
import { last, mix } from 'shared';
import { mix } from 'shared';
import { serverTimeline } from '../server-timeline';
interface Frame {
@ -7,25 +7,16 @@ interface Frame {
velocity: number;
}
// How far past the newest snapshot the value may coast on its last known
// velocity (network hiccup) before freezing in place.
const maxCoastSeconds = 0.06;
// When fresh snapshots arrive after a coast, they usually disagree with where
// the coast ended up; the difference decays away with this time constant
// instead of being shown as a jump.
const blendSeconds = 0.12;
// At 25 snapshots per second this spans over a second of state, far more than
// rendering ever needs; it only bounds memory while the tab is hidden and
// snapshots keep arriving without being consumed.
const maxFrames = 32;
/**
* Replays a server-streamed scalar by interpolating between timestamped
* snapshots at the shared timeline's render time, which trails the newest
* snapshot. The streamed rate of change is only used to coast briefly when
* the buffer runs dry.
* snapshots at the render time; coasts briefly on the streamed rate of change
* when the buffer runs dry.
*/
export class LinearInterpolator {
private frames: Array<Frame> = [];
@ -39,7 +30,7 @@ export class LinearInterpolator {
public addFrame(value: number, rateOfChange: number) {
const time = serverTimeline.snapshotTime;
const newest = last(this.frames);
const newest = this.frames.length > 0 ? this.frames[this.frames.length - 1] : null;
const wasCoasting = this.isCoasting;
if (newest && time <= newest.time) {
@ -56,8 +47,6 @@ export class LinearInterpolator {
}
if (wasCoasting) {
// Continue from where the coast left off and let the offset decay,
// instead of jumping onto the freshly revealed trajectory.
this.blendOffset = this.lastValue - this.sample(serverTimeline.renderTime);
}
}

View file

@ -1,21 +0,0 @@
import { vec2 } from 'gl-matrix';
import { LinearInterpolator } from './linear-interpolator';
export class Vec2Interpolator {
private x: LinearInterpolator;
private y: LinearInterpolator;
constructor(currentValue: vec2) {
this.x = new LinearInterpolator(currentValue.x);
this.y = new LinearInterpolator(currentValue.y);
}
public addFrame(value: vec2, rateOfChange: vec2) {
this.x.addFrame(value.x, rateOfChange.x);
this.y.addFrame(value.y, rateOfChange.y);
}
public getValue(deltaTime: number): vec2 {
return vec2.fromValues(this.x.getValue(deltaTime), this.y.getValue(deltaTime));
}
}

View file

@ -1,13 +1,3 @@
import { vec2 } from 'gl-matrix';
export class Pointer {
private static displayPosition: vec2 | null = null;
public static setDisplayPosition(x: number, y: number): void {
Pointer.displayPosition = vec2.fromValues(x, y);
}
public static getDisplayPosition(): vec2 | null {
return Pointer.displayPosition;
}
}
export const pointer = { displayPosition: null as vec2 | null };

View file

@ -9,8 +9,7 @@ import {
resolveCircleMovement,
} from 'shared';
// What the predictor needs to know about a planet to collide and be pulled by
// it. The client reads this off its PlanetViews.
// What the predictor needs to know about a planet to collide and be pulled by it.
export interface PredictablePlanet {
id: Id;
vertices: Array<vec2>;
@ -20,9 +19,7 @@ export interface PredictablePlanet {
rotationSpeed: number;
}
// A planet collision/gravity surface whose rotation can be advanced during
// replay, so its outline turns in lockstep with the body the carry term moves —
// exactly as the server steps the planet before the character each tick.
// A planet surface whose rotation advances during replay, in lockstep with the body — exactly as the server steps the planet before the character each tick.
class PlanetSurface implements GroundSurface {
public readonly canCollide = true;
public readonly isGround = true;
@ -71,18 +68,12 @@ class PlanetSurface implements GroundSurface {
}
}
// The planets-only collision world the local predictor runs against. It holds
// persistent surfaces keyed by planet id (so a `currentPlanet` reference stays
// valid across frames) and never dispatches collision reactions — damage,
// scoring and the like are server-authoritative.
// Planets-only collision world for the predictor. Persistent surfaces keyed by id (currentPlanet stays valid across frames); never dispatches collision reactions — those are server-authoritative.
export class ClientCharacterWorld implements CharacterWorld {
private surfaces = new Map<Id, PlanetSurface>();
private ordered: Array<PlanetSurface> = [];
// Refresh from the current PlanetViews. Far planets contribute zero gravity
// (the falloff clamps to 0 past maxGravityDistance) and never collide, so the
// whole set can be handed to every query without a range filter. Ordered by
// id so the (rare) two-surface contact picks a stable surface.
// Far planets contribute zero gravity (the falloff clamps to 0 past maxGravityDistance) and never collide, so the whole set can be handed to every query without a range filter. Ordered by id so the (rare) two-surface contact picks a stable surface.
public sync(planets: Array<PredictablePlanet>) {
const seen = new Set<Id>();
for (const planet of planets) {
@ -104,10 +95,7 @@ export class ClientCharacterWorld implements CharacterWorld {
.map((e) => e[1]);
}
// Advance every planet's collision frame by one replay substep, so surfaces
// and the carried body rotate together. The surfaces are re-synced to the
// newest snapshot rotation each frame (see sync), so the replay only ever
// steps forward from there.
// Advance every planet's collision frame by one replay substep, so surfaces and the carried body rotate together. Surfaces are re-synced to the newest snapshot rotation each frame (see sync), so the replay only ever steps forward from there.
public advance(deltaTimeInSeconds: number) {
for (const surface of this.ordered) {
surface.advance(deltaTimeInSeconds);

View file

@ -5,16 +5,9 @@ interface InputSample {
direction: vec2;
}
// Keep a little over a second of input — far more than any sane reconciliation
// window — so a brief stall (or a backgrounded tab catching up) can still be
// replayed, while old samples are pruned to bound memory.
const retainMs = 1500;
// A timeline of the local player's movement directions in client-clock time.
// Each generated MoveActionCommand is stamped with the time this hands out, and
// the same sample is recorded here so the predictor replays exactly the input
// the server will eventually receive. Direction is piecewise-constant: the
// active direction at any instant is the most recent sample at or before it.
// Timeline of local movement directions; the predictor replays exactly the input the server receives.
export class InputHistory {
private samples: Array<InputSample> = [];
@ -27,8 +20,6 @@ export class InputHistory {
}
}
// The held direction at `timeMs`: the latest sample at or before it, or zero
// before any input exists.
public directionAt(timeMs: number): vec2 {
let direction = vec2.create();
for (const sample of this.samples) {

View file

@ -14,49 +14,26 @@ import {
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
import { InputHistory } from './input-history';
// The server's fixed tick, read from the same place the server reads it — the
// replay integrates over this dt, so a comment promising they match is not
// enough.
// Must match the server's tick source — the replay integrates over this dt.
const stepSeconds = settings.targetPhysicsDeltaTimeInSeconds;
const stepMs = stepSeconds * 1000;
// 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.
// Frame timestamp, not performance.now() — dispatch jitter would land in the replay window.
let frameTimeMs = 0;
export const setFrameTimeMs = (timeMs: number): void => {
frameTimeMs = timeMs;
};
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
// instead of grinding through hundreds of steps. Comfortably covers the replay
// a 300 ms round trip needs; beyond that the body simply trails a little.
const maxReplayMs = 400;
// Render-side easing of the correction the reconciliation produces each frame,
// so a snapshot that disagrees with the prediction is smoothed out instead of
// popping. Short, so the local player still feels immediate.
const smoothSeconds = 0.06;
// A correction bigger than this isn't prediction error — it's a respawn,
// teleport, or a server-side impulse (leap / slingshot / recoil / death throw)
// the predictor doesn't model. Snap to it rather than gliding across the gap.
const snapDistance = 250;
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.
// Velocity starts at zero — the sim rebuilds it each tick, so it is not carried state.
const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
center: vec2.clone(center),
radius,
@ -65,68 +42,22 @@ const makeBody = (center: vec2, radius: number, normal: vec2): PhysicsBody => ({
restitution: 0,
});
// Predicts the local player's character so it responds to input immediately,
// instead of lagging ~100 ms + RTT behind like the interpolated remote objects.
// Each frame it resets to the latest authoritative snapshot and replays the
// player's own un-acknowledged input through the SAME movement simulation the
// server runs (shared/stepCharacterMovement), then eases the rendered pose
// toward the result. Discrete server-side impulses it can't model show up as a
// large correction and snap rather than rubber-band.
export class LocalCharacterPredictor {
private readonly inputHistory = new InputHistory();
private readonly world = new ClientCharacterWorld();
private authoritative?: { head: Circle; leftFoot: Circle; rightFoot: Circle };
// Client-clock instant the authoritative pose below belongs to, less one
// one-way trip — which is exactly the lead the local body should have, since
// the input the player is giving right now will not reach the server for that
// long. Replaying from here reproduces every input the server has not applied
// yet, so the body responds immediately at any latency.
//
// It is built from BOTH halves of the acknowledgement: the client-clock time
// of the newest input the server had applied, plus how long after that the
// snapshot was actually taken. Both halves are needed.
//
// Anchoring on the acknowledged input time ALONE beats against the send
// 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
// real latency. This anchor degenerates to arrival on a zero-latency link and
// grows into full compensation as latency rises.
//
// A held key sends no fresh movement, which would freeze the acknowledgement
// altogether; that is fixed at the source, by closing every outgoing batch
// with a ClientHeartbeatCommand.
// Replay anchor: built from both halves of the ack (input time + ackAge), advances forward only.
private replayAnchorMs?: number;
// 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<number> = [];
// clientTimeMs of the last leap the server has folded into the streamed
// momentum. Leaps at or before this are already in movement.bodyVelocity;
// only newer ones are replayed, so a leap is never applied twice.
// Last leap the server folded into streamed momentum; newer ones only, so a leap is never applied twice.
private lastLeapAckMs = -Infinity;
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
private currentStrength = settings.playerMaxStrength;
// Whether the local body is alive on the server. While dead (awaiting respawn)
// prediction is suppressed so the corpse/ghost can't keep walking in response
// to input — the server ignores a dead player's movement, so a predicted body
// that still moved would be a pure client-side desync.
// Dead players don't predict — the server ignores their input, so moving would desync.
private alive = true;
// The eased, rendered pose handed to the view.
private renderHead = new Circle(vec2.create(), headRadius);
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
private renderRightFoot = new Circle(vec2.create(), feetRadius);
@ -142,8 +73,6 @@ export class LocalCharacterPredictor {
return this.renderRightFoot;
}
// 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(frameTimeMs);
this.inputHistory.record(direction, timeMs);
@ -156,8 +85,7 @@ export class LocalCharacterPredictor {
lastLeapClientTimeMs: number,
ackAgeMs = 0,
): void {
// Inputs only advance the acknowledgement forward; the movement state and
// leap boundary always adopt the latest authoritative values.
// The anchor only advances forward; 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;
@ -170,10 +98,7 @@ export class LocalCharacterPredictor {
this.authoritative = { head, leftFoot, rightFoot };
}
// The player pressed leap; remember when, so the replay applies the same
// impulse the server will. Recorded regardless of whether the server accepts
// it — a rejected leap (no strength/cooldown) self-corrects via the streamed
// authoritative momentum.
// Recorded regardless of acceptance — a rejected leap self-corrects via streamed momentum.
public recordLeap(): number {
const timeMs = Math.round(frameTimeMs);
this.leapHistory.push(timeMs);
@ -212,10 +137,7 @@ export class LocalCharacterPredictor {
);
}
// During spawn-in and death the server freezes walking and only scales the
// body (CharacterPhysical.step returns early), so its head radius is below
// nominal. Predicting then would walk the body off a position the server is
// holding still — let interpolation show the animation instead.
// While spawn-in/death the server freezes walking (head radius < nominal) — don't predict, let interpolation show it.
private get isAnimatingInOrOut(): boolean {
return (
this.authoritative !== undefined &&
@ -223,13 +145,8 @@ export class LocalCharacterPredictor {
);
}
// Run one frame of prediction. Returns true if it produced a rendered pose the
// caller should use (otherwise fall back to interpolation). `planets` is the
// current collision world; `frameSeconds` is the render delta.
public update(planets: Array<PredictablePlanet>, frameSeconds: number): boolean {
if (!this.alive || !this.canPredict || this.isAnimatingInOrOut) {
// Resume from the authoritative pose with a snap rather than gliding from
// a stale rendered one.
this.hasRender = false;
return false;
}
@ -250,21 +167,14 @@ export class LocalCharacterPredictor {
const auth = this.authoritative!;
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.
// Replay unconfirmed input from anchor to now, clamped so a stalled tab can't grind hundreds of steps.
const startMs = Math.min(now, 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;
// 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.
// Full authoritative state — replay is a pure function of (snapshot, input, window) and cannot drift.
const state: CharacterMovementState = {
// 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,
@ -279,22 +189,14 @@ export class LocalCharacterPredictor {
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.
// Leaps before the window are baked in; inside it they're re-applied — neither double-counted.
bodyVelocity: vec2.fromValues(movement.bodyVelocity[0], movement.bodyVelocity[1]),
};
// The planet collision frames were synced (in update(), just before this)
// to the NEWEST snapshot's rotation — the same instant the authoritative
// body pose is from — so the body and the surface start the replay at the
// same phase. The loop below advances the surfaces FORWARD in lockstep with
// the body's carry from that shared phase, so no rewind is needed (and the
// persistent surfaces are re-synced next frame, so this never accumulates).
// Planet frames are synced to the newest snapshot's rotation, then advanced forward in lockstep with the body — no rewind needed.
const cooldownMs = settings.leapCooldownSeconds * 1000;
// Mirror the server: each accepted leap spends leapStrengthCost, so a burst
// of leaps in one replay window is gated by the running strength rather than
// a single up-front affordability check.
// Mirror the server: each accepted leap spends strength, so a burst is gated by running strength.
let availableStrength = this.currentStrength;
let lastLeapMs = -Infinity;
@ -305,15 +207,11 @@ export class LocalCharacterPredictor {
stepCharacterMovement(state, this.world, input, stepSeconds);
this.world.advance(stepSeconds);
// A leap issued during this step launches now (the next step injects the
// momentum), gated like the server: on a surface, off cooldown, with
// strength. applyLeapImpulse is a no-op off-surface.
for (const leapMs of this.leapHistory) {
if (
leapMs >= t &&
leapMs < t + stepMs &&
// Only leaps the server hasn't yet folded into the seed, so a leap
// is never both seeded and replayed.
// Only leaps the server hasn't seeded — never both seeded and replayed.
leapMs > this.lastLeapAckMs &&
state.currentPlanet &&
leapMs - lastLeapMs >= cooldownMs &&
@ -328,10 +226,7 @@ export class LocalCharacterPredictor {
t += stepMs;
}
// Final sub-tick of the leftover (< stepMs) so the predicted pose is a
// continuous function of the window length rather than advancing in 5 ms
// quanta — the floor() above would otherwise surface that as a per-frame
// wobble on top of the ~16.7 ms render cadence.
// Sub-tick the remainder so the pose is continuous in the window length, not quantized to stepMs.
if (remainderSeconds > 0) {
tickPlanetDetachment(state, remainderSeconds);
stepCharacterMovement(

View file

@ -1,27 +1,15 @@
import { clamp, settings } from 'shared';
// Convergence rate of the playback cursor towards its target; a deviation
// decays with a time constant of 1 / rateGain seconds.
const rateGain = 2;
// Playback speed stays within [0.75, 1.25]× so corrections are invisible.
const maxRateAdjustment = 0.25;
// Beyond this divergence (tab was in the background, server changed) chasing
// the target is pointless: jump straight to it.
const resyncSeconds = 0.3;
/**
* The playback clock for state streamed from the server.
*
* Snapshot timestamps estimate the server's clock: the newest received
* timestamp plus the time elapsed since it arrived. Rendering happens
* settings.interpolationDelaySeconds behind that estimate, so there is
* normally a newer snapshot to interpolate towards and network jitter is
* absorbed by the buffer instead of being shown.
*
* The cursor never jumps under normal operation: it runs at a gently adjusted
* rate to stay on target and only snaps after a long divergence.
* Playback clock for streamed server state — renders interpolationDelaySeconds
* behind the server clock so a newer snapshot is usually available; the cursor
* adjusts its rate gently and only snaps after a long divergence.
*/
class ServerTimeline {
private cursor?: number;
@ -29,12 +17,10 @@ class ServerTimeline {
private sinceNewestSnapshot = 0;
private _snapshotTime = 0;
/** Timestamp of the update batch currently being applied. */
public get snapshotTime(): number {
return this._snapshotTime;
}
/** The point on the server's clock that should be rendered this frame. */
public get renderTime(): number {
return this.cursor ?? 0;
}

View file

@ -1,7 +0,0 @@
export const show = (e: HTMLElement, useDisplay = false, normalDisplayValue?: string) => {
if (useDisplay) {
e.style.display = normalDisplayValue!;
} else {
e.style.visibility = 'inherit';
}
};