This commit is contained in:
Andras Schmelczer 2026-06-14 20:58:42 +01:00
commit fc9df09ee1
3 changed files with 2012 additions and 252 deletions

View file

@ -0,0 +1,138 @@
import { vec2 } from 'gl-matrix';
import {
CharacterWorld,
GroundSurface,
Id,
PhysicsBody,
planetDistance,
planetGravity,
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.
export interface PredictablePlanet {
id: Id;
vertices: Array<vec2>;
center: vec2;
radius: number;
rotation: number;
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.
class PlanetSurface implements GroundSurface {
public readonly canCollide = true;
public readonly isGround = true;
public readonly id: Id;
public center: vec2;
public angularVelocity: number;
private vertices: Array<vec2>;
private radius: number;
private rotation = 0;
private cos = 1;
private sin = 0;
constructor(planet: PredictablePlanet) {
this.id = planet.id;
this.center = planet.center;
this.vertices = planet.vertices;
this.radius = planet.radius;
this.angularVelocity = planet.rotationSpeed;
this.setRotation(planet.rotation);
}
public sync(planet: PredictablePlanet) {
this.center = planet.center;
this.vertices = planet.vertices;
this.radius = planet.radius;
this.angularVelocity = planet.rotationSpeed;
this.setRotation(planet.rotation);
}
private setRotation(rotation: number) {
this.rotation = rotation;
this.cos = Math.cos(rotation);
this.sin = Math.sin(rotation);
}
public advance(deltaTimeInSeconds: number) {
this.setRotation(this.rotation + this.angularVelocity * deltaTimeInSeconds);
}
public distance(target: vec2): number {
return planetDistance(target, this.vertices, this.center, this.cos, this.sin);
}
public gravityAt(target: vec2): vec2 {
return planetGravity(this.center, this.radius, target);
}
}
// 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.
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.
public sync(planets: Array<PredictablePlanet>) {
const seen = new Set<Id>();
for (const planet of planets) {
seen.add(planet.id);
const existing = this.surfaces.get(planet.id);
if (existing) {
existing.sync(planet);
} else {
this.surfaces.set(planet.id, new PlanetSurface(planet));
}
}
for (const id of [...this.surfaces.keys()]) {
if (!seen.has(id)) {
this.surfaces.delete(id);
}
}
this.ordered = [...this.surfaces.entries()]
.sort((a, b) => Number(a[0]) - Number(b[0]))
.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.
public advance(deltaTimeInSeconds: number) {
for (const surface of this.ordered) {
surface.advance(deltaTimeInSeconds);
}
}
public surfaceById(id: Id | undefined): GroundSurface | undefined {
return id == null ? undefined : this.surfaces.get(id);
}
public idOf(surface: GroundSurface | undefined): Id | undefined {
return surface instanceof PlanetSurface ? surface.id : undefined;
}
public groundsNear(): Array<GroundSurface> {
return this.ordered;
}
public stepBody(
body: PhysicsBody,
deltaTimeInSeconds: number,
): GroundSurface | undefined {
const { hitObject } = resolveCircleMovement(body, deltaTimeInSeconds, this.ordered);
return hitObject && (hitObject as GroundSurface).isGround
? (hitObject as GroundSurface)
: undefined;
}
}

View file

@ -0,0 +1,47 @@
import { vec2 } from 'gl-matrix';
interface InputSample {
timeMs: number;
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.
export class InputHistory {
private samples: Array<InputSample> = [];
public record(direction: vec2, timeMs: number): void {
this.samples.push({ timeMs, direction: vec2.clone(direction) });
const cutoff = timeMs - retainMs;
while (this.samples.length > 1 && this.samples[1].timeMs <= cutoff) {
this.samples.shift();
}
}
// 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) {
if (sample.timeMs <= timeMs) {
direction = sample.direction;
} else {
break;
}
}
return vec2.clone(direction);
}
public reset(): void {
this.samples = [];
}
}

View file

@ -0,0 +1,328 @@
import { vec2 } from 'gl-matrix';
import {
Circle,
CharacterMovementState,
Id,
PhysicsBody,
settings,
stepCharacterMovement,
applyLeapImpulse,
tickPlanetDetachment,
feetRadius,
headRadius,
} from 'shared';
import { ClientCharacterWorld, PredictablePlanet } from './client-character-world';
import { InputHistory } from './input-history';
const stepMs = 1000 / 200; // match the server's 200 Hz fixed tick
const stepSeconds = 1 / 200;
// Don't replay more than this far back: if the last acknowledged input is older
// (a stall, or a backgrounded tab catching up) snap to the authoritative pose
// instead of grinding through hundreds of steps.
const maxReplayMs = 300;
// 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 makeBody = (center: vec2, radius: number): PhysicsBody => ({
center: vec2.clone(center),
radius,
velocity: vec2.create(),
lastNormal: vec2.fromValues(0, 1),
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 };
private lastAckClientTimeMs?: number;
// Wall-clock (client) time the latest authoritative snapshot was received. The
// replay predicts forward from HERE by the snapshot's age, so the local pose
// advances smoothly with real time between the 25 Hz snapshots. Anchoring to
// the last *acked input* time instead breaks when input is sent only on change
// (a held key sends nothing): that time freezes, the window pins to the
// maxReplayMs clamp, the replay displacement goes constant, and the pose
// stair-steps at the snapshot rate.
private authReceiptMs = 0;
// Authoritative launch momentum at the last snapshot — seeds each replay so a
// leap/slingshot/recoil flight is reproduced and continuously corrected.
private authoritativeBodyVelocity = vec2.create();
// Wall-clock times the player issued a leap, replayed (with the impulse
// applied locally) so the launch is felt immediately, not after a round trip.
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 authoritativeBodyVelocity;
// only newer ones are replayed, so a leap is never applied twice.
private lastLeapAckMs = -Infinity;
// Latest streamed shooting-strength, to gate predicted leaps as the server does.
private currentStrength = settings.playerMaxStrength;
// Continuous state carried between replays (the snapshot carries only poses).
// The facing direction is NOT carried — it is re-derived from the pose each
// frame (see directionFromPose / simulate); only the latched planet and the
// time-since-surface persist.
private carriedPlanetId?: Id;
private carriedSecondsSinceSurface = 1;
// The eased, rendered pose handed to the view.
private renderHead = new Circle(vec2.create(), headRadius);
private renderLeftFoot = new Circle(vec2.create(), feetRadius);
private renderRightFoot = new Circle(vec2.create(), feetRadius);
private hasRender = false;
public get head(): Circle {
return this.renderHead;
}
public get leftFoot(): Circle {
return this.renderLeftFoot;
}
public get rightFoot(): Circle {
return this.renderRightFoot;
}
// Stamp a movement command and record it for replay. Returns the wall-clock
// time the command should carry so the server can echo it back.
public recordInput(direction: vec2): number {
const timeMs = Math.round(performance.now());
this.inputHistory.record(direction, timeMs);
return timeMs;
}
public acknowledge(
clientTimeMs: number,
bodyVelocity: vec2,
lastLeapClientTimeMs: number,
): void {
// Inputs only advance the acknowledgement forward; the launch momentum and
// leap boundary always adopt the latest authoritative values.
if (this.lastAckClientTimeMs === undefined || clientTimeMs > this.lastAckClientTimeMs) {
this.lastAckClientTimeMs = clientTimeMs;
}
vec2.set(this.authoritativeBodyVelocity, bodyVelocity[0], bodyVelocity[1]);
this.lastLeapAckMs = lastLeapClientTimeMs;
}
public setAuthoritative(head: Circle, leftFoot: Circle, rightFoot: Circle): void {
this.authoritative = { head, leftFoot, rightFoot };
this.authReceiptMs = Math.round(performance.now());
}
// 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.
public recordLeap(): number {
const timeMs = Math.round(performance.now());
this.leapHistory.push(timeMs);
const cutoff = timeMs - 1500;
while (this.leapHistory.length > 0 && this.leapHistory[0] <= cutoff) {
this.leapHistory.shift();
}
return timeMs;
}
public setStrength(strength: number): void {
this.currentStrength = strength;
}
public reset(): void {
this.inputHistory.reset();
this.leapHistory = [];
this.lastLeapAckMs = -Infinity;
this.authoritative = undefined;
this.lastAckClientTimeMs = undefined;
this.authReceiptMs = 0;
vec2.zero(this.authoritativeBodyVelocity);
this.currentStrength = settings.playerMaxStrength;
this.carriedPlanetId = undefined;
this.carriedSecondsSinceSurface = 1;
this.hasRender = false;
}
public get canPredict(): boolean {
return this.authoritative !== undefined && this.lastAckClientTimeMs !== undefined;
}
// 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.
private get isAnimatingInOrOut(): boolean {
return (
this.authoritative !== undefined &&
this.authoritative.head.radius < headRadius - 0.5
);
}
// 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.canPredict || this.isAnimatingInOrOut) {
// Resume from the authoritative pose with a snap rather than gliding from
// a stale rendered one.
this.hasRender = false;
return false;
}
this.world.sync(planets);
const predicted = this.simulate();
if (!this.hasRender) {
this.snapRenderTo(predicted);
this.hasRender = true;
} else {
this.easeRenderTo(predicted, frameSeconds);
}
return true;
}
// The body's facing angle is encoded in the pose: each part is sprung toward
// center + R(direction)*offset and the head's offset points +y, so
// direction = atan2(head - center) - PI/2. Re-deriving it from the snapshot
// each frame (rather than carrying the previous frame's evolved value onto
// this past pose, re-evolved by a variable substep count) keeps the posture
// seed a pure function of the snapshot — carrying it fed a frame-rate-dependent
// loop that wobbled the rendered limbs.
private directionFromPose(head: Circle, leftFoot: Circle, rightFoot: Circle): number {
const cx = (head.center[0] + leftFoot.center[0] + rightFoot.center[0]) / 3;
const cy = (head.center[1] + leftFoot.center[1] + rightFoot.center[1]) / 3;
return Math.atan2(head.center[1] - cy, head.center[0] - cx) - Math.PI / 2;
}
private simulate(): CharacterMovementState {
const auth = this.authoritative!;
const now = Math.round(performance.now());
// Predict forward from the latest snapshot by its age, clamped so a stall
// (or a backgrounded tab catching up) snaps instead of grinding hundreds of
// steps. This advances with wall-clock time even while a held key sends no
// fresh input, so the pose no longer pins to the 25 Hz snapshot cadence.
const startMs = Math.max(this.authReceiptMs, now - maxReplayMs);
const windowMs = Math.max(0, now - startMs);
const steps = Math.floor(windowMs / stepMs);
const remainderSeconds = (windowMs - steps * stepMs) / 1000;
const state: CharacterMovementState = {
head: makeBody(auth.head.center, auth.head.radius),
leftFoot: makeBody(auth.leftFoot.center, auth.leftFoot.radius),
rightFoot: makeBody(auth.rightFoot.center, auth.rightFoot.radius),
direction: this.directionFromPose(auth.head, auth.leftFoot, auth.rightFoot),
currentPlanet: this.world.surfaceById(this.carriedPlanetId),
secondsSinceOnSurface: this.carriedSecondsSinceSurface,
// Leaps before the replay window are already baked into this; leaps inside
// the window are re-applied below, so neither is double-counted.
bodyVelocity: vec2.clone(this.authoritativeBodyVelocity),
};
// 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).
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.
let availableStrength = this.currentStrength;
let lastLeapMs = -Infinity;
let t = startMs;
for (let i = 0; i < steps; i++) {
const input = this.inputHistory.directionAt(t);
tickPlanetDetachment(state, stepSeconds);
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.
leapMs > this.lastLeapAckMs &&
state.currentPlanet &&
leapMs - lastLeapMs >= cooldownMs &&
availableStrength >= settings.leapStrengthCost
) {
applyLeapImpulse(state, this.inputHistory.directionAt(leapMs));
availableStrength -= settings.leapStrengthCost;
lastLeapMs = leapMs;
}
}
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.
if (remainderSeconds > 0) {
tickPlanetDetachment(state, remainderSeconds);
stepCharacterMovement(
state,
this.world,
this.inputHistory.directionAt(now),
remainderSeconds,
);
this.world.advance(remainderSeconds);
}
this.carriedPlanetId = this.world.idOf(state.currentPlanet);
this.carriedSecondsSinceSurface = state.secondsSinceOnSurface;
return state;
}
private snapRenderTo(state: CharacterMovementState): void {
this.renderHead = new Circle(vec2.clone(state.head.center), state.head.radius);
this.renderLeftFoot = new Circle(
vec2.clone(state.leftFoot.center),
state.leftFoot.radius,
);
this.renderRightFoot = new Circle(
vec2.clone(state.rightFoot.center),
state.rightFoot.radius,
);
}
private easeRenderTo(state: CharacterMovementState, frameSeconds: number): void {
const q = 1 - Math.exp(-frameSeconds / smoothSeconds);
this.easePart(this.renderHead, state.head, q);
this.easePart(this.renderLeftFoot, state.leftFoot, q);
this.easePart(this.renderRightFoot, state.rightFoot, q);
}
private easePart(render: Circle, target: PhysicsBody, q: number): void {
if (vec2.distance(render.center, target.center) > snapDistance) {
vec2.copy(render.center, target.center);
} else {
vec2.lerp(render.center, render.center, target.center, q);
}
render.radius = target.radius;
}
}
export const localCharacterPredictor = new LocalCharacterPredictor();