rook/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Interpolate remote bodies between samples instead of chasing them

`now.x += (x - now.x) * 0.35` every frame is a different speed at 60Hz than
at 144Hz, and it is a pursuit that never arrives: every other player was
permanently behind and rubbery.

interp.js keeps the last few samples per body with the times they landed and
draws the frame at `now - delay`, about one snapshot interval in the past, so
a body walks between the two samples that bracket that moment at the speed
the server moved it and arrives exactly where the server put it. The delay
follows the rate snapshots actually arrive at, so the same code is right at
15Hz and 30Hz. A missed snapshot carries the last pair's velocity for one
interval and then holds — never further. Angles turn the short way round,
and a body that was out of sight, or that jumped further than anyone can
run, starts fresh rather than sliding across the map.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed d9114b2 Browse files
3 files changed +327 −18
src/interp.test.ts added +165
@@ -0,0 +1,165 @@
1 /**
2 * Entity interpolation: a pure function of two samples and a time.
3 *
4 * What is checked here is what a browser cannot be asked about — that the
5 * body walks between the pair at the server's own speed, that a missed
6 * snapshot is carried exactly one interval and no further, that the turn is
7 * taken the short way round, and that a time between the pair never draws
8 * the body anywhere but between them.
9 */
10
11 import { expect, test } from "bun:test";
12 import { arrive, bodyAt, delay, feed, GONE, LAG, LAG_MAX, LAG_MIN, makeTimeline, sample } from "../static/interp.js";
13
14 type Mark = { t: number; x: number; y: number; angle: number };
15 const pair = (a: Mark, b: Mark) => [a, b];
16
17 const walk = pair({ t: 10, x: 100, y: 200, angle: 0 }, { t: 10.1, x: 120, y: 200, angle: 1 });
18
19 test("between the pair it walks at the speed the server moved it", () => {
20 expect(sample(walk, 10)).toMatchObject({ x: 100, y: 200 });
21 expect(sample(walk, 10.05).x).toBeCloseTo(110, 6);
22 expect(sample(walk, 10.1)).toMatchObject({ x: 120, y: 200 });
23 // Constant speed: equal steps of time are equal steps of distance, which
24 // is the whole difference from an exponential chase that never arrives.
25 const a = sample(walk, 10.02).x;
26 const b = sample(walk, 10.04).x;
27 const c = sample(walk, 10.06).x;
28 expect(b - a).toBeCloseTo(c - b, 6);
29 });
30
31 test("a time inside the pair is never a position outside it", () => {
32 const diagonal = pair({ t: 1, x: -30, y: 80, angle: 2 }, { t: 1.07, x: 45, y: -10, angle: -2 });
33 for (let when = 1; when <= 1.07; when += 0.002) {
34 const at = sample(diagonal, when);
35 expect(at.x).toBeGreaterThanOrEqual(-30);
36 expect(at.x).toBeLessThanOrEqual(45);
37 expect(at.y).toBeGreaterThanOrEqual(-10);
38 expect(at.y).toBeLessThanOrEqual(80);
39 }
40 });
41
42 test("before the older sample it holds rather than running backwards", () => {
43 expect(sample(walk, 9.5)).toMatchObject({ x: 100, y: 200 });
44 expect(sample(walk, 0)).toMatchObject({ x: 100, y: 200 });
45 });
46
47 test("a missed snapshot is carried one interval and then held", () => {
48 // Half an interval late: half an interval of the pair's own velocity.
49 expect(sample(walk, 10.15).x).toBeCloseTo(130, 6);
50 // A whole one: exactly one more interval of it.
51 expect(sample(walk, 10.2).x).toBeCloseTo(140, 6);
52 // And never a step further, however long the silence lasts.
53 expect(sample(walk, 10.3).x).toBeCloseTo(140, 6);
54 expect(sample(walk, 30).x).toBeCloseTo(140, 6);
55 expect(sample(walk, 30).angle).toBeCloseTo(2, 6);
56 });
57
58 test("two samples at one instant are one sample", () => {
59 const still = pair({ t: 5, x: 7, y: 8, angle: 0.5 }, { t: 5, x: 7, y: 8, angle: 0.5 });
60 expect(sample(still, 9)).toMatchObject({ x: 7, y: 8, angle: 0.5 });
61 });
62
63 test("the turn is taken the short way round", () => {
64 const spin = pair({ t: 0, x: 0, y: 0, angle: 3.0 }, { t: 0.1, x: 0, y: 0, angle: -3.0 });
65 // Not back through zero: onward past π, by a sixth of a radian or so.
66 expect(sample(spin, 0.05).angle).toBeCloseTo(3.0 + 0.2831853 / 2, 6);
67 const back = pair({ t: 0, x: 0, y: 0, angle: -3.0 }, { t: 0.1, x: 0, y: 0, angle: 3.0 });
68 expect(sample(back, 0.05).angle).toBeCloseTo(-3.0 - 0.2831853 / 2, 6);
69 });
70
71 // ------------------------------------------------------------- the timeline
72
73 const body = (id: string, x: number, y = 0, angle = 0) => ({ id, x, y, angle });
74
75 test("a body is drawn where the server says until there are two of it", () => {
76 const timeline = makeTimeline();
77 const first = body("a", 50);
78 expect(bodyAt(timeline, first, 10)).toBe(first);
79 feed(timeline, "a", first, 10);
80 expect(bodyAt(timeline, first, 10.2)).toMatchObject({ x: 50, y: 0 });
81 feed(timeline, "a", body("a", 60), 10.1);
82 expect(bodyAt(timeline, body("a", 60), 10.05).x).toBeCloseTo(55, 6);
83 });
84
85 test("each body keeps its own answer: one can be held while another is asked", () => {
86 const timeline = makeTimeline();
87 feed(timeline, "a", body("a", 0), 1);
88 feed(timeline, "a", body("a", 10), 1.1);
89 feed(timeline, "b", body("b", 100), 1);
90 feed(timeline, "b", body("b", 110), 1.1);
91 const a = bodyAt(timeline, body("a", 10), 1.05);
92 const b = bodyAt(timeline, body("b", 110), 1.05);
93 expect(a.x).toBeCloseTo(5, 6);
94 expect(b.x).toBeCloseTo(105, 6);
95 });
96
97 test("a body that was out of sight starts again rather than sliding back", () => {
98 const timeline = makeTimeline();
99 feed(timeline, "a", body("a", 0), 1);
100 feed(timeline, "a", body("a", 10), 1.1);
101 feed(timeline, "a", body("a", 900), 1.1 + GONE + 0.1);
102 expect(bodyAt(timeline, body("a", 900), 1.1 + GONE + 0.15).x).toBeCloseTo(900, 6);
103 });
104
105 test("a jump no one could have run is a respawn, not a walk", () => {
106 const timeline = makeTimeline();
107 feed(timeline, "a", body("a", 0), 1);
108 feed(timeline, "a", body("a", 10), 1.1);
109 // A third of a second's running is 80-odd units; 600 is a teleport.
110 feed(timeline, "a", body("a", 610), 1.2);
111 expect(bodyAt(timeline, body("a", 610), 1.25).x).toBeCloseTo(610, 6);
112 });
113
114 test("a sample that arrives out of order is dropped", () => {
115 const timeline = makeTimeline();
116 feed(timeline, "a", body("a", 0), 1);
117 feed(timeline, "a", body("a", 10), 1.1);
118 feed(timeline, "a", body("a", 999), 1.05);
119 expect(bodyAt(timeline, body("a", 10), 1.1).x).toBeCloseTo(10, 6);
120 });
121
122 test("the delay follows the rate the snapshots actually arrive at", () => {
123 const slow = makeTimeline();
124 for (let i = 0; i < 60; i++) arrive(slow, 10 + i / 15);
125 const fast = makeTimeline();
126 for (let i = 0; i < 60; i++) arrive(fast, 10 + i / 30);
127 expect(delay(slow)).toBeGreaterThan(delay(fast));
128 expect(delay(slow)).toBeCloseTo((1 / 15) * LAG, 3);
129 // Thirty a second wants less of a delay, and both stay inside the bounds.
130 for (const d of [delay(slow), delay(fast)]) {
131 expect(d).toBeGreaterThanOrEqual(LAG_MIN);
132 expect(d).toBeLessThanOrEqual(LAG_MAX);
133 }
134 // A pause — a backgrounded tab, a reconnect — says nothing about the rate.
135 arrive(fast, 40);
136 expect(delay(fast)).toBeCloseTo(LAG_MIN, 3);
137 });
138
139 test("the moment being drawn is always bracketed by samples on hand", () => {
140 // Thirty snapshots a second, a frame every 16ms: the moment being drawn
141 // always falls between the two samples on hand, never before them.
142 const timeline = makeTimeline();
143 let x = 0;
144 let last = { t: 0, x: 0 };
145 for (let i = 0; i < 40; i++) {
146 const at = 10 + i / 30;
147 arrive(timeline, at);
148 x += 8;
149 feed(timeline, "a", body("a", x), at);
150 last = { t: at, x };
151 if (i < 3) continue;
152 for (let f = 0; f < 2; f++) {
153 const now = at + f * 0.016;
154 const marks = timeline.bodies.get("a")!.marks as Mark[];
155 const when = now - delay(timeline);
156 expect(when).toBeGreaterThanOrEqual(marks[0]!.t - 1e-9);
157 expect(when).toBeLessThanOrEqual(marks[marks.length - 1]!.t + 1e-9);
158 }
159 }
160 // And late is what late means: behind the newest word by the delay, which
161 // at eight units an interval is between one and two of them.
162 const drawn = bodyAt(timeline, body("a", last.x), last.t - delay(timeline));
163 expect(last.x - drawn.x).toBeGreaterThan(0);
164 expect(last.x - drawn.x).toBeLessThan(20);
165 });
static/interp.js added +146
@@ -0,0 +1,146 @@
1 /**
2 * Everybody else, drawn between the things the server said about them.
3 *
4 * A remote body is only ever heard about a few dozen times a second, so it
5 * has to be drawn somewhere in between. The old way was an exponential chase
6 * — `now.x += (x - now.x) * 0.35` every frame — which is wrong twice: it is a
7 * different speed at 60Hz than at 144Hz, and it is a pursuit that never
8 * arrives, so every other player was permanently behind and rubbery.
9 *
10 * This is entity interpolation instead. Each body keeps the last few samples
11 * with the times they landed, and the frame is drawn at `now - delay`, about
12 * a snapshot interval in the past. That moment falls between two samples on
13 * hand, so the body moves at exactly the speed the server moved it and
14 * arrives exactly where the server put it — one interval late, which nobody
15 * can see. A handful of samples rather than strictly two is what buys the
16 * delay a margin: a snapshot that arrives late or early still brackets the
17 * moment being drawn instead of stalling the body.
18 *
19 * When a snapshot really is missed the newest pair's own velocity is carried
20 * forward for one interval and then held: a guess that long is better than a
21 * stall, and a guess longer than that is fiction. The delay follows the rate
22 * snapshots actually arrive at, so the same code is right at 15Hz and at
23 * 30Hz without being told which it is.
24 *
25 * Nothing here is ever sent anywhere: it is a drawing, and the server is
26 * still the only thing that says where anybody is.
27 */
28
29 /** The render delay, as a multiple of the interval snapshots arrive at. */
30 export const LAG = 1.3;
31 /** ...never tighter than this, never looser, in seconds. */
32 export const LAG_MIN = 0.045;
33 export const LAG_MAX = 0.13;
34 /** How many samples a body keeps: enough to bracket the delay, and no more. */
35 export const KEEP = 4;
36 /** How fast the measured interval follows the arrivals. */
37 const INTERVAL_PULL = 0.25;
38 /** A gap this long means the body was gone, not slow: start it again. */
39 export const GONE = 0.4;
40 /** Faster than anything can run: a respawn, or a body back from nowhere. */
41 const TOP_SPEED = 400;
42
43 const clamp = (n, low, high) => (n < low ? low : n > high ? high : n);
44
45 /** The short way round from one angle to another, in (-π, π]. */
46 export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
47
48 /** One timeline for the whole snapshot stream: the bodies, and the rate. */
49 export const makeTimeline = (interval = 1 / 15) => ({ bodies: new Map(), interval, at: 0 });
50
51 /**
52 * A snapshot landed at `at` seconds. The gap since the last one is what the
53 * render delay is built out of; a gap wilder than any real rate (a tab that
54 * was in the background, a reconnect) says nothing about the rate and is
55 * left out of it.
56 */
57 export function arrive(timeline, at) {
58 const gap = at - timeline.at;
59 if (timeline.at > 0 && gap > 1 / 120 && gap < 0.5)
60 timeline.interval += (gap - timeline.interval) * INTERVAL_PULL;
61 timeline.at = at;
62 return timeline;
63 }
64
65 /** How far behind the present this timeline is drawn, in seconds. */
66 export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX);
67
68 /** The moment being drawn, given the clock now. */
69 export const renderTime = (timeline, now) => now - delay(timeline);
70
71 const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle });
72
73 /**
74 * What the server just said about one body. Out-of-order samples are
75 * dropped; a body that has been away, or that has moved further than anyone
76 * can run, starts fresh rather than sliding across the map to its new place.
77 *
78 * The drawn position lives on the track — one object per body, so a caller
79 * may hold the answer it was given while asking about somebody else.
80 */
81 export function feed(timeline, id, body, at) {
82 const track = timeline.bodies.get(id);
83 if (!track) {
84 timeline.bodies.set(id, { marks: [mark(at, body)], out: { x: 0, y: 0, angle: 0 } });
85 return timeline;
86 }
87 const last = track.marks[track.marks.length - 1];
88 if (at <= last.t) return timeline;
89 const gap = at - last.t;
90 const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * gap + 4;
91 if (gap > GONE || jumped) track.marks.length = 0;
92 track.marks.push(mark(at, body));
93 if (track.marks.length > KEEP) track.marks.shift();
94 return timeline;
95 }
96
97 /**
98 * Where a body was at a moment, given the samples it has — ascending in
99 * time, and at least one.
100 *
101 * Between two samples this is a straight walk from one to the other at the
102 * server's own speed. Before the oldest it holds, because running backwards
103 * to meet a moment we have no answer for would be inventing one. After the
104 * newest it carries the last pair's velocity for one interval — no more,
105 * ever — and then holds there.
106 */
107 export function sample(marks, when, out = { x: 0, y: 0, angle: 0 }) {
108 const last = marks[marks.length - 1];
109 if (marks.length === 1 || when >= last.t) return beyond(marks, when, out);
110 if (when <= marks[0].t) return at(marks[0], marks[0], 0, out);
111 let i = marks.length - 2;
112 while (i > 0 && marks[i].t > when) i -= 1;
113 const from = marks[i];
114 const to = marks[i + 1];
115 const span = to.t - from.t;
116 return at(from, to, span > 0 ? (when - from.t) / span : 1, out);
117 }
118
119 /** Past the newest sample: one interval of the last pair's velocity, held. */
120 function beyond(marks, when, out) {
121 const last = marks[marks.length - 1];
122 const before = marks[marks.length - 2];
123 if (!before) return at(last, last, 0, out);
124 const span = last.t - before.t;
125 if (span <= 0) return at(last, last, 0, out);
126 return at(before, last, clamp((when - before.t) / span, 1, 2), out);
127 }
128
129 /** A point `u` of the way from one sample to another; the turn is short. */
130 function at(from, to, u, out) {
131 out.x = from.x + (to.x - from.x) * u;
132 out.y = from.y + (to.y - from.y) * u;
133 out.angle = from.angle + shortWay(from.angle, to.angle) * u;
134 return out;
135 }
136
137 /**
138 * Where to draw a body out of a snapshot. One with no samples yet — the
139 * first frame it is seen on — is drawn where the server says, which is the
140 * only honest answer available.
141 */
142 export function bodyAt(timeline, body, when, out = null) {
143 const track = timeline.bodies.get(body.id);
144 if (!track) return body;
145 return sample(track.marks, when, out ?? track.out);
146 }
static/val.js +16 −18
@@ -12,6 +12,7 @@
1212 "use strict";
1313
1414 import { makeBeeper } from "./beep.js";
15 import { arrive, bodyAt, feed, makeTimeline, renderTime } from "./interp.js";
1516 import { BODY_R, moveSpeed } from "../src/glide.ts";
1617 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1718 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
@@ -37,7 +38,11 @@ let world = null; // what predict.js slides against: the grid, from map.json
3738 let snap = null;
3839 let socket = null;
3940 let agents = null;
40 const shown = new Map(); // smoothed positions, so 15 frames a second look like 60
41 // Everybody else, kept as the last few things the server said about them and
42 // drawn between those — see interp.js. `drawAt` is the moment this frame is
43 // drawing, a snapshot interval behind the present.
44 const timeline = makeTimeline();
45 let drawAt = 0;
4146 const keys = new Set();
4247 // Where this person is looking: the browser's between snapshots, and the
4348 // server's word taken only when it is saying something we did not. See
@@ -82,6 +87,11 @@ function connect() {
8287 if (data.t !== "s") return;
8388 snap = data;
8489 snapAt = performance.now();
90 // Timed samples for every body in it, ours included: the drawing walks
91 // between them rather than chasing the newest one.
92 arrive(timeline, snapAt / 1000);
93 feed(timeline, data.me.id, data.me, snapAt / 1000);
94 for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000);
8595 // The server's angle is taken only when it is a correction, never as the
8696 // routine echo of the angle we sent it a moment ago.
8797 settle(head, data.me, snapAt / 1000);
@@ -283,26 +293,13 @@ addEventListener("resize", resize);
283293 const scale = () => (map ? view.width / (map.rows[0].length * map.tile) : 1);
284294 const worldToScreen = (p) => ({ x: p.x * scale(), y: p.y * scale() });
285295
286 function smooth(id, x, y, angle) {
287 const now = shown.get(id);
288 if (!now) {
289 const fresh = { x, y, angle };
290 shown.set(id, fresh);
291 return fresh;
292 }
293 now.x += (x - now.x) * 0.35;
294 now.y += (y - now.y) * 0.35;
295 const turn = ((angle - now.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
296 now.angle += turn * 0.4;
297 return now;
298 }
299
300296 /**
301 * Everybody else is smoothed toward the last position the server sent.
297 * Everybody else is drawn at `drawAt`: between the two samples that bracket
298 * it, so they move at the server's own speed and arrive where it put them.
302299 * You are not: your own body is the one this browser can run forward
303 * honestly, and smoothing it is what dragged the camera backwards.
300 * honestly, and drawing it late is what dragged the camera backwards.
304301 */
305 const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle));
302 const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(timeline, p, drawAt));
306303
307304 /**
308305 * Your own movement is only guessed at while the round is live and you are
@@ -345,6 +342,7 @@ function frame(now) {
345342 requestAnimationFrame(frame);
346343 const dt = Math.min(0.1, (now - last) / 1000);
347344 last = now;
345 drawAt = renderTime(timeline, now / 1000);
348346 if (!map) return;
349347 gunTick(gun, dt);
350348 ctx.clearRect(0, 0, view.width, view.height);
↑↓ navigate ↵ open esc close

Keyboard shortcuts