benjivers/val Public Public: anyone can read this; the crew can change it.

File snapshots on an even clock, hold the lag instead of yanking it, and count the jumps

The player still said it was not smooth after the last three fixes, so this
starts by measuring instead of trusting the list. A headless wire between the
real match and the real client modules (src/feel.test.ts) — thirty snapshots a
second, arriving with the jitter that was measured on the box (p50 33ms, p99
41–48ms), drawn at sixty — puts numbers on what was left:

  own body walking     step spread 17.0%  ->  0.0%
  walk start           yanked back 4.9    ->  0.0 backward, never below full speed
  everybody else       step spread 17.3%  ->  7.3%, restarts 0
  render clock         11.9–19.0ms/frame  ->  16.3–17.0ms/frame
  round start          camera swung east a tick after facing the site  ->  faces the site

Three causes, none of them throughput.

The wire's rhythm was being drawn. Samples were filed under the time they
landed, so a snapshot a few milliseconds late and the next a few early made a
body walk slow then fast between them, and made your own snapshot's age — the
lead your keys are carried by — jump at every arrival. interp.js now files
arrivals on an even clock: one interval after the last, pulled a tenth of the
way toward when each really came, trimmed if they keep landing on one side,
never ahead of an arrival by more than half a beat (a burst from a background
tab must not walk it into the future), never backwards. The moment being drawn
advances at real time and creeps toward its delay at 2% of it, so the delay
adapting is not a short frame. The delay itself sat at 45ms and was wobbling
to 61; it now holds 45.0–45.2.

The lag was being called a disagreement. Your keys take a send interval, a hop
and a tick to show in the server's answer; the guess ran ahead by exactly that
and was pulled back onto the answer — a hard clamp at every snapshot, so every
start of movement was a yank backwards and the steady walk beat with the wire.
predict.js now lets the drawn body lead the projected answer by LEAD_SLACK
(80ms of running) along the way it is going, leaves that part alone, leans
across-track and behind-the-server gaps at the old rate, and after a stop holds
for the lag and then leans off gently. Barriers an ability put up are refused
the way the map's walls are, so the guess cannot stand inside one; nobody
moves in the end and over phases, so the guess does not either. The frame's
composition moved from val.js into one stepLocal so the harness can drive it.

The server stomped its own spawn facing. startRound pointed everybody at the
site and then reset their input to an empty one whose angle is zero, and the
next tick copied that onto the player: two camera jumps in the first hundred
milliseconds of every round. The input now keeps the facing the spawn gave.

Also: the gun kicks the moment the trigger is pulled, and the snapshot's word
about that shot is not a second kick (the server still fires and lands it);
the ` readout prints the count of corrections taken outright — anchors,
restarts, re-pointings — and the rate a minute, so a stutter has a number.

Two tests were changed because the rule they encoded changed: the client
test's bound on how far the drawn body may lead a stopped server now includes
LEAD_SLACK, and its text check on where a refused step is held follows the
code into stepLocal.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Fable committed ad62f80 Browse files
10 files changed +679 −71
README.md +27 −9
@@ -59,17 +59,32 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
5959 snapshot forward by up to a quarter second of held input. It is a drawing
6060 optimisation only: nothing is sent, every snapshot leans the drawn body
6161 back onto the server's answer, and a real disagreement is taken outright.
62 The lag your own keys take to reach the server (a send every 50ms, a hop,
63 a tick — `LEAD_SLACK` in `static/predict.js`) is not a disagreement: the
64 drawn body may lead the answer by that much along the way it is going and
65 is left alone there, so a start is not yanked back and a stop is not
66 dragged on. Anywhere the server would refuse — a wall, the spawn pen in
67 the buy phase, a barrier an ability put up — the drawing holds its last
68 good place rather than snapping to the server's. The gun kicks the moment
69 the trigger is pulled, and the server's word about the same shot is not a
70 second kick; whether it landed is still only the server's to say.
6271 Where you are *looking* is the browser's too: the mouse moves it at frame
6372 rate, and the server's angle is taken back only when it is telling you
6473 something you did not say (`static/yaw.js`).
6574 - Everybody else is drawn by entity interpolation (`static/interp.js`): each
66 body keeps the last few snapshots with the times they landed, and the frame
67 is drawn about one snapshot interval in the past, between the two that
68 bracket that moment. So they move at the speed the server moved them and
69 arrive where it put them, one interval late. A missed snapshot is carried
70 one interval further and then held. Snapshots go out every tick (30Hz, some
71 3.4 KiB a seat) while two or fewer seats are filled, and every other tick
72 above that.
75 body keeps the last few snapshots, and the frame is drawn about one
76 snapshot interval in the past, between the two that bracket that moment.
77 So they move at the speed the server moved them and arrive where it put
78 them, one interval late. A missed snapshot is carried one interval further
79 and then held. Snapshots are filed under an even clock — one interval
80 after the last, nudged a tenth of the way toward when each really landed —
81 rather than the moment they happened to arrive, because a rate is not a
82 rhythm: thirty a second delivered a few milliseconds early and late is a
83 body drawn slow, then fast. The moment being drawn advances at real time
84 and creeps toward its delay at no more than two percent of it, so the
85 delay adapting never shows as a short frame. Snapshots go out every tick
86 (30Hz, some 3.4 KiB a seat) while two or fewer seats are filled, and every
87 other tick above that.
7388 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
7489 no looking up or down, and everything that is not a wall — bodies, the
7590 spike, ability zones — is a flat billboard scaled by distance and clipped
@@ -119,8 +134,11 @@ defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab**
119134 holds the big map open, **V** swaps to the top-down view, **H** or **?**
120135 brings back the how-to-play card, and **F3** or **`** prints the frame
121136 readout: frame rate, mean frame, snapshots a second, the interpolation delay
122 in force and the width the world is being drawn at. On a phone it stays top-down: the left
123 thumb walks and the right thumb aims and fires.
137 in force, the width the world is being drawn at, and how many times the
138 drawing has taken the server's word outright (your body anchored, somebody
139 else started again, the view re-pointed) — every one of those is a jump, and
140 the number a minute is what "it stutters" should be argued with. On a phone
141 it stays top-down: the left thumb walks and the right thumb aims and fires.
124142
125143 ```sh
126144 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
src/client.test.ts +12 −8
@@ -363,9 +363,11 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
363363 // in time (predict.js LEAD_MAX), so the bound has to be read off the same
364364 // speed table the client used, or it measures the match's mood instead of
365365 // the rule.
366 // The lead is the cap plus the slack the guess is allowed over the answer
367 // (predict.js LEAD_SLACK: the lag your own keys take to reach the server).
366368 const { moveSpeed } = await import("./glide.ts");
367 const { LEAD_MAX } = await import("../static/predict.js");
368 expect(lead).toBeLessThan(LEAD_MAX * moveSpeed(seat) + 10);
369 const { LEAD_MAX, LEAD_SLACK } = await import("../static/predict.js");
370 expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10);
369371 expect(Math.abs(sideways)).toBeLessThan(6);
370372 // Nothing on the server moved: this is a drawing, and the match is still
371373 // the only thing that says where anybody is.
@@ -410,6 +412,9 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
410412 expect(readout).toContain("Hz in");
411413 expect(readout).toContain("ms behind");
412414 expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
415 // And the count of corrections taken outright, per minute: a stutter is a
416 // jump, and this is how many there have been.
417 expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/);
413418 press("Backquote");
414419 expect((perf.style as Record<string, string>).display).toBe("none");
415420
@@ -479,10 +484,9 @@ test("the buy phase is predicted, and its pen is the server's own rule", async (
479484 expect(bundle).toContain('snap.phase !== "buy" || pens !== null');
480485 // And the pen is the server's number, not a guess: 120 from a spawn tile.
481486 expect(bundle).toContain("best > 120");
482 // A refused step holds where it was rather than snapping to the server.
483 const predict = bundle.slice(bundle.indexOf("function predict("));
484 const holds = predict.indexOf("local.x = was.x");
485 const anchors = predict.indexOf("anchor(local, snap.me)");
486 expect(holds).toBeGreaterThan(-1);
487 expect(holds).toBeLessThan(anchors); // hold first; anchor only if still stuck
487 // A refused step holds where it was rather than snapping to the server:
488 // the pen is handed to the one step function as the place it is refused.
489 expect(bundle).toContain("stepLocal(local, snap.me, held, speed");
490 expect(bundle).toContain("world, refused)");
491 expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)");
488492 });
src/feel.test.ts added +363
@@ -0,0 +1,363 @@
1 /**
2 * The whole feel, measured.
3 *
4 * A wire between the real match and the real client modules: snapshots leave
5 * on the server's ticks, arrive a little late and a little unevenly, and the
6 * browser side draws sixty frames a second from them exactly the way val.js
7 * does. What comes out is a set of numbers a person's "it stutters" can be
8 * held against — how even the per-frame steps are, how often a correction
9 * jumps, how large the largest jump is — and every one of them is a rule
10 * this file enforces from here on.
11 */
12
13 import { expect, test } from "bun:test";
14 import { BODY_R, moveSpeed, RUN } from "./glide.ts";
15 import { solidTile, TILE } from "./map.ts";
16 import { newMatch, setInput, startRound, tick } from "./match.ts";
17 import { runBots } from "./bots.ts";
18 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/interp.js";
19 import { makeLocal, stepLocal } from "../static/predict.js";
20 import { makeYaw, settle, told } from "../static/yaw.js";
21
22 const round2 = (n: number) => Math.round(n * 10) / 10;
23 const world = { solid: solidTile, tile: TILE, r: BODY_R };
24
25 /** A seeded wobble, so the run is the same every time. */
26 function rng(seed: number) {
27 let s = seed | 0;
28 return () => {
29 s = (s + 0x6d2b79f5) | 0;
30 let t = Math.imul(s ^ (s >>> 15), s | 1);
31 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
32 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
33 };
34 }
35
36 /**
37 * The wire as it was measured: 30 a second, p50 gap 33ms, p99 41–48ms, no
38 * gap over 100ms. Each packet takes a base latency plus a wobble, and now
39 * and then one is held a dozen milliseconds longer.
40 */
41 function jitter(random: () => number): number {
42 const u = random();
43 const v = random();
44 const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v);
45 return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0);
46 }
47
48 const spreadOf = (steps: number[]) => {
49 const mean = steps.reduce((s, x) => s + x, 0) / steps.length;
50 const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length);
51 return { mean, spread: sd / mean };
52 };
53
54 type Body = { id: string; x: number; y: number; angle: number };
55 type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string }; bodies: Body[] };
56
57 /**
58 * A person walking east across open ground for two seconds, sending intent
59 * every 50ms, hearing back thirty times a second over a jittery wire, and
60 * drawn at sixty frames — with the input reaching the server one way late.
61 */
62 function walkOwnBody(base = 0.02) {
63 const random = rng(7);
64 const state = newMatch(11);
65 const me = state.players[0]!;
66 me.bot = false;
67 for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) {
68 runBots(state, 1 / 30);
69 tick(state, 1 / 30);
70 }
71 // Somewhere open: the middle of the map, facing east.
72 me.x = 1000;
73 me.y = 640;
74 me.hp = 100;
75 me.alive = true;
76 me.haste = 0;
77 me.slow = 0;
78 me.weapon = "classic";
79 const packets: Packet[] = [];
80 const inputs: { at: number; mx: number; my: number }[] = [];
81 const held = { mx: 0, my: 0 };
82 const walkFrom = 0.3;
83 const walkUntil = 1.6;
84 // The server's clock: one tick every 33ms; snapshots leave right after.
85 for (let k = 0; k * (1 / 30) < 2.2; k++) {
86 const t = k / 30;
87 while (inputs.length && inputs[0]!.at <= t) {
88 const input = inputs.shift()!;
89 setInput(me, { mx: input.mx, my: input.my, angle: 0 });
90 }
91 // The browser's send timer is on its own clock: every 50ms, arriving one
92 // way of latency later.
93 for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) {
94 if (s * 0.05 < t - 1 / 30) continue;
95 const sentAt = s * 0.05;
96 const moving = sentAt >= walkFrom && sentAt < walkUntil;
97 inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 });
98 }
99 inputs.sort((a, b) => a.at - b.at);
100 tick(state, 1 / 30);
101 packets.push({
102 at: t + base + jitter(random),
103 stamp: 0,
104 me: { id: me.id, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), alive: me.alive, phase: state.phase },
105 bodies: [],
106 });
107 }
108 packets.sort((a, b) => a.at - b.at);
109
110 const timeline = makeTimeline();
111 const local = makeLocal();
112 let snap: Packet | null = null;
113 let next = 0;
114 let previous = 0;
115 const steps: number[] = [];
116 let back = 0;
117 let anchors = 0;
118 for (let f = 0; f / 60 < 2.2; f++) {
119 const now = f / 60;
120 while (next < packets.length && packets[next]!.at <= now) {
121 snap = packets[next]!;
122 snap.stamp = arrive(timeline, snap.at);
123 next += 1;
124 }
125 if (!snap) continue;
126 held.mx = now >= walkFrom && now < walkUntil ? 1 : 0;
127 const before = local.anchored;
128 stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null);
129 // The first word about where you are is taken outright: that one is not a jump.
130 if (before > 0) anchors += local.anchored - before;
131 if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) {
132 const step = local.x - previous;
133 steps.push(step);
134 back = Math.min(back, step);
135 }
136 previous = local.x;
137 }
138 return { steps, back, anchors, local, server: me };
139 }
140
141 test("your own body, walking, takes even steps and never a backward one", () => {
142 const { steps, back, anchors } = walkOwnBody();
143 const { mean, spread } = spreadOf(steps);
144 console.log(
145 `own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
146 `largest backward step ${back.toFixed(2)}, anchors ${anchors}`,
147 );
148 // Running east at 250 a second is 4.17 a frame.
149 expect(mean * 60).toBeGreaterThan(RUN * 0.9);
150 expect(mean * 60).toBeLessThan(RUN * 1.1);
151 // Even: the per-frame step does not beat against the wire's jitter.
152 expect(spread).toBeLessThan(0.05);
153 // And a step backwards is a stutter by definition.
154 expect(back).toBeGreaterThan(-0.01);
155 expect(anchors).toBe(0);
156 });
157
158 test("starting and stopping never pulls you back, and the speed never dips a third", () => {
159 // The first tenth of a second after the key goes down the server has not
160 // heard yet, and the guess used to be yanked back to the server's still
161 // body on each of the first two snapshots. Letting go, the server runs on
162 // for the same lag and the guess must not be dragged back to meet it.
163 const random = rng(3);
164 const timeline = makeTimeline();
165 const local = makeLocal();
166 const me = { x: 1000, y: 640, angle: 0, alive: true };
167 const held = { mx: 0, my: 0 };
168 const hop = 0.03; // one way, plus the send timer's own phase
169 let serverX = 1000;
170 const packets: { at: number; x: number }[] = [];
171 for (let k = 0; k / 30 < 2; k++) {
172 const t = k / 30;
173 // The server hears the key a hop after the next 50ms send.
174 const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop;
175 const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop;
176 if (t > heardDown && t <= heardUp) serverX += RUN / 30;
177 packets.push({ at: t + hop + jitter(random), x: round2(serverX) });
178 }
179 let next = 0;
180 let previous = 0;
181 let back = 0;
182 let slowest = Infinity;
183 for (let f = 0; f / 60 < 2; f++) {
184 const now = f / 60;
185 while (next < packets.length && packets[next]!.at <= now) {
186 me.x = packets[next]!.x;
187 arrive(timeline, packets[next]!.at);
188 next += 1;
189 }
190 if (next === 0) continue;
191 held.mx = now >= 0.3 && now < 1.2 ? 1 : 0;
192 stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null);
193 const step = local.x - previous;
194 if (now > 0.3 + 1 / 60) back = Math.min(back, step);
195 if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step);
196 previous = local.x;
197 }
198 console.log(
199 `walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` +
200 `settled ${(local.x - serverX).toFixed(1)} from the server`,
201 );
202 expect(back).toBeGreaterThan(-0.01);
203 expect(slowest).toBeGreaterThan((RUN / 60) * 0.67);
204 // Where you stopped is where the server stops: the guess settles onto it.
205 expect(Math.abs(local.x - serverX)).toBeLessThan(1);
206 });
207
208 /**
209 * Everybody else: a bot walking, heard about thirty times a second over the
210 * same wire, drawn a little in the past between the samples on hand.
211 */
212 function watchBots() {
213 const random = rng(5);
214 const state = newMatch(23);
215 const step = () => {
216 runBots(state, 1 / 30);
217 tick(state, 1 / 30);
218 };
219 for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step();
220 const packets: { at: number; bodies: Body[] }[] = [];
221 for (let k = 0; k < 90; k++) {
222 step();
223 packets.push({
224 at: k / 30 + 0.02 + jitter(random),
225 bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })),
226 });
227 }
228 const timeline = makeTimeline();
229 const paths = new Map<string, { x: number; y: number }[]>();
230 const delays: number[] = [];
231 let ahead = 0;
232 let frames = 0;
233 let next = 0;
234 for (let f = 0; f / 60 < 2.8; f++) {
235 const now = f / 60;
236 while (next < packets.length && packets[next]!.at <= now) {
237 const stamp = arrive(timeline, packets[next]!.at);
238 for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp);
239 next += 1;
240 }
241 if (next < 4) continue;
242 const drawAt = renderTime(timeline, now);
243 delays.push(now - drawAt);
244 frames += 1;
245 if (drawAt > timeline.stamp) ahead += 1;
246 for (const p of packets[next - 1]!.bodies) {
247 const at = bodyAt(timeline, p, drawAt);
248 paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]);
249 }
250 }
251 const walked = [...paths.values()]
252 .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y)))
253 .map((steps) => steps.filter((s) => s > 0.001))
254 .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!;
255 return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline };
256 }
257
258 test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => {
259 const { walked, restarts, delays, ahead, frames } = watchBots();
260 const { mean, spread } = spreadOf(walked);
261 const dmin = Math.min(...delays);
262 const dmax = Math.max(...delays);
263 console.log(
264 `others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
265 `restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` +
266 `drawn past the newest sample ${ahead}/${frames} frames`,
267 );
268 expect(walked.length).toBeGreaterThan(40);
269 expect(mean * 60).toBeGreaterThan(150);
270 expect(spread).toBeLessThan(0.08);
271 // A body that was in sight all along never jumps to start again.
272 expect(restarts).toBe(0);
273 // The moment drawn is never later than the newest word: no guessing ahead.
274 expect(ahead).toBe(0);
275 });
276
277 test("the render clock never steps backwards or stalls, whatever the wire does", () => {
278 const random = rng(9);
279 const timeline = makeTimeline();
280 let arrival = 0.02;
281 let drawn = 0;
282 let worstShort = Infinity;
283 let worstLong = 0;
284 for (let f = 0; f / 60 < 5; f++) {
285 const now = f / 60;
286 while (arrival <= now) {
287 arrive(timeline, arrival);
288 arrival += 1 / 30 + jitter(random) - 0.002;
289 }
290 const at = renderTime(timeline, now);
291 if (f > 30 && drawn > 0) {
292 worstShort = Math.min(worstShort, at - drawn);
293 worstLong = Math.max(worstLong, at - drawn);
294 }
295 drawn = at;
296 }
297 console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`);
298 // Within a few percent of real time, every frame: 16.67ms give or take.
299 expect(worstShort * 60).toBeGreaterThan(0.97);
300 expect(worstLong * 60).toBeLessThan(1.03);
301 });
302
303 test("a burst of snapshots does not walk the even clock into the future", () => {
304 // A tab back from the background is handed everything it missed at once.
305 // The clock those samples are filed on may lead the arrivals by half a
306 // beat and no more, or the sent angles age out and every echo of your own
307 // view comes back as a correction.
308 const timeline = makeTimeline();
309 let at = 10;
310 for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30));
311 const before = timeline.stamp;
312 for (let i = 0; i < 150; i++) {
313 const stamp = arrive(timeline, (at += 0.0002));
314 expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9);
315 expect(stamp).toBeGreaterThanOrEqual(before);
316 }
317 // And once the wire is itself again, the beat is back on it.
318 for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30));
319 expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01);
320 });
321
322 test("a round start points you at the site once, and nothing stomps it a tick later", () => {
323 const state = newMatch(31);
324 const me = state.players[0]!;
325 me.bot = false;
326 const yaw = makeYaw();
327 let corrections = 0;
328 let worstRound = 0;
329 let at = 0;
330 const hear = () => {
331 const before = yaw.angle;
332 settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at);
333 if (yaw.angle !== before) corrections += 1;
334 };
335 hear();
336 // A dozen round starts, each followed by a second of ticks with the client
337 // echoing back exactly what it was pointed at, the way the input timer
338 // does — and each echo reaching the server a tick after it was sent.
339 const inFlight: number[] = [];
340 for (let round = 0; round < 12; round++) {
341 const were = corrections;
342 startRound(state);
343 hear(); // the snapshot that follows the tick which started the round
344 for (let k = 0; k < 30; k++) {
345 at += 1 / 30;
346 if (inFlight.length) setInput(me, { angle: inFlight.shift()! });
347 if (k % 2 === 0) {
348 told(yaw, yaw.angle, at);
349 inFlight.push(yaw.angle);
350 }
351 tick(state, 1 / 30);
352 hear();
353 }
354 // Facing the site the server put us at, still, a second later.
355 const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 };
356 const want = Math.atan2(centre.y - me.y, centre.x - me.x);
357 expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11);
358 worstRound = Math.max(worstRound, corrections - were);
359 }
360 console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`);
361 // At most one a round — the spawn — and never a second one to angle zero.
362 expect(worstRound).toBeLessThanOrEqual(1);
363 });
src/gun.test.ts +26 −1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { fade, firedNow, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js";
2 import { fade, firedNow, gunFire, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js";
33
44 const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({
55 me: { x: 100, y: 100, ammo: 25, reloading: false, alive: true, ...over },
@@ -44,6 +44,31 @@ test("a landed shot marks, and the mark fades on its own clock", () => {
4444 expect(gun.mark).toBe(0);
4545 });
4646
47 test("pulling the trigger kicks the gun now, and the snapshot's word is not a second kick", () => {
48 const gun = makeGun();
49 gunSnapshot(gun, snap());
50 expect(gunFire(gun, snap())).toBe(true);
51 expect(gun.kick).toBe(1);
52 // The kick is well under way when the server's word arrives: no restart.
53 gunTick(gun, 0.08);
54 const shown = gun.kick;
55 gunSnapshot(gun, snap({ ammo: 24 }));
56 expect(gun.kick).toBe(shown);
57 // A second shot the server fired on its own (a held trigger) still kicks.
58 gunSnapshot(gun, snap({ ammo: 23 }));
59 expect(gun.kick).toBe(1);
60 // No kick with nothing to fire: dead, reloading, or empty.
61 expect(gunFire(makeGun(), snap({ alive: false }))).toBe(false);
62 expect(gunFire(makeGun(), snap({ reloading: true }))).toBe(false);
63 expect(gunFire(makeGun(), snap({ ammo: 0 }))).toBe(false);
64 // A kick the server never answered is forgotten once it has faded.
65 const refused = makeGun();
66 gunFire(refused, snap());
67 gunTick(refused, 1);
68 gunSnapshot(refused, snap());
69 expect(refused.owed).toBe(0);
70 });
71
4772 test("fade never goes below nothing", () => {
4873 expect(fade(0.1, 10, 0.2)).toBe(0);
4974 });
src/match.ts +6 −1
@@ -235,12 +235,17 @@ export function startRound(state: MatchState, first = false): void {
235235 player.goal = null;
236236 player.think = 0;
237237 player.aim = emptyTrack();
238 player.input = emptyInput();
239238 // Basics are bought fresh every round; the signature comes back free.
240239 player.charges = { c: 0, q: 0, e: 1, x: 0 };
241240 player.cooldown = { c: 0, q: 0, e: 0, x: 0 };
242241 if (first) player.credits = START_CREDITS;
243242 placeAtSpawn(state, player, byTeam[player.team]++);
243 // The keys are let go of, but the facing the spawn gave is kept as the
244 // input's own: an empty input's angle is zero, and the next tick copies
245 // the input's angle onto the player, so a round used to point everybody
246 // at the site and then swing them east a tick later — a camera that
247 // jumped twice in the first hundred milliseconds of every round.
248 player.input = { ...emptyInput(), angle: player.angle };
244249 }
245250 giveSpike(state);
246251 }
static/gun.js +23 −2
@@ -6,7 +6,8 @@
66 * shots the server sent, the same ones the top-down view draws.
77 */
88
9 export const makeGun = () => ({ kick: 0, mark: 0, ammo: null });
9 /** `owed` is how many kicks have been shown ahead of the server saying a shot happened. */
10 export const makeGun = () => ({ kick: 0, mark: 0, ammo: null, owed: 0 });
1011
1112 /** Recoil and the hit marker both fade the same way: fast, and to zero. */
1213 export const fade = (value, dt, life) => Math.max(0, value - dt / life);
@@ -24,9 +25,29 @@ export function hitMarked(shots, me) {
2425 return false;
2526 }
2627
28 /**
29 * The trigger was pulled here, now. If the gun can fire — alive, loaded, not
30 * reloading — it kicks at once rather than a round trip later, and the kick
31 * the server's snapshot would have given for the same shot is owed and not
32 * shown twice. Whether the shot lands, and whether it even happened, is the
33 * server's alone: a kick is the hand, not the bullet.
34 */
35 export function gunFire(gun, snap) {
36 if (!snap.me.alive || snap.me.reloading || snap.me.ammo <= 0) return false;
37 if (gun.kick > 0.5) return false; // a held trigger, not a new pull
38 gun.kick = 1;
39 gun.owed += 1;
40 return true;
41 }
42
2743 /** Fold a fresh snapshot into the gun's state: kick on a shot, mark on a hit. */
2844 export function gunSnapshot(gun, snap) {
29 if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) gun.kick = 1;
45 if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) {
46 if (gun.owed > 0) gun.owed -= 1;
47 else gun.kick = 1;
48 }
49 // A kick owed for longer than a shot could take to come back was refused.
50 if (gun.owed > 0 && gun.kick === 0) gun.owed = 0;
3051 if (snap.me.alive && hitMarked(snap.shots, snap.me)) gun.mark = 1;
3152 gun.ammo = snap.me.ammo;
3253 }
static/interp.js +85 −10
@@ -34,7 +34,15 @@ export const LAG_MAX = 0.13;
3434 /** How many samples a body keeps: enough to bracket the delay, and no more. */
3535 export const KEEP = 4;
3636 /** How fast the measured interval follows the arrivals. */
37 const INTERVAL_PULL = 0.25;
37 const INTERVAL_PULL = 0.1;
38 /** How fast the even clock snapshots are filed under is pulled toward the arrivals. */
39 const STAMP_PULL = 0.1;
40 /** How much of the even clock's lateness is read as the interval being wrong. */
41 const INTERVAL_TRIM = 0.02;
42 /** How far the moment drawn may run fast or slow to reach its delay: a share of real time. */
43 export const SLEW = 0.02;
44 /** A render clock this far off its mark is not late, it is stopped: start it again. */
45 const RESYNC = 0.25;
3846 /** A gap this long means the body was gone, not slow: start it again. */
3947 export const GONE = 0.4;
4048 /** Faster than anything can run: a respawn, or a body back from nowhere. */
@@ -45,28 +53,90 @@ const clamp = (n, low, high) => (n < low ? low : n > high ? high : n);
4553 /** The short way round from one angle to another, in (-π, π]. */
4654 export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
4755
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 });
56 /**
57 * One timeline for the whole snapshot stream: the bodies, the rate, the even
58 * clock (`stamp`) and the moment being drawn (`drawAt`). `restarts` counts
59 * the times a body was started again rather than walked: every one is a jump.
60 */
61 export const makeTimeline = (interval = 1 / 30) => ({
62 bodies: new Map(),
63 interval,
64 at: 0,
65 stamp: 0,
66 drawAt: 0,
67 drawnNow: 0,
68 restarts: 0,
69 });
5070
5171 /**
5272 * A snapshot landed at `at` seconds. The gap since the last one is what the
5373 * render delay is built out of; a gap wilder than any real rate (a tab that
5474 * was in the background, a reconnect) says nothing about the rate and is
5575 * left out of it.
76 *
77 * Returns the time the snapshot is filed under, which is not the time it
78 * landed. The server sends thirty a second, evenly; the network hands them
79 * over in a rhythm of its own, a few milliseconds early or late, sometimes
80 * two nearly together. A sample filed under its arrival time carries that
81 * wobble into the walk between samples — a body drawn between a sample that
82 * came early and one that came late moves slow, then fast — and into the
83 * age of your own snapshot, which is how far ahead your own keys are
84 * carried. So arrivals are filed on an even clock instead: one interval
85 * after the last, pulled a tenth of the way toward when they really came so
86 * it never drifts. A rate is not a rhythm; this is the rhythm.
5687 */
5788 export function arrive(timeline, at) {
5889 const gap = at - timeline.at;
59 if (timeline.at > 0 && gap > 1 / 120 && gap < 0.5)
60 timeline.interval += (gap - timeline.interval) * INTERVAL_PULL;
90 const steady = timeline.at > 0 && gap > 1 / 120 && gap < 0.5;
91 if (steady) timeline.interval += (gap - timeline.interval) * INTERVAL_PULL;
92 const expected = timeline.stamp + timeline.interval;
93 const off = at - expected;
94 // Within an interval of the beat, keep the beat; further out (a pause, a
95 // reconnect, the rate itself changing) the arrival is the only clock there is.
96 let stamp = at;
97 if (timeline.at > 0 && gap < 0.5 && Math.abs(off) < timeline.interval) {
98 stamp = expected + off * STAMP_PULL;
99 // Arrivals that keep landing on the same side of the beat mean the beat
100 // is wrong, not late: trim the interval by a little of the lateness.
101 timeline.interval += off * INTERVAL_TRIM;
102 }
103 // A sample is never filed before the one before it, and never more than
104 // half a beat before it actually came: a burst of snapshots (a tab coming
105 // back from the background) must not walk the clock off into the future.
106 if (stamp <= timeline.stamp) stamp = timeline.stamp + timeline.interval / 4;
107 stamp = Math.max(Math.min(stamp, at + timeline.interval / 2), timeline.stamp);
61108 timeline.at = at;
62 return timeline;
109 timeline.stamp = stamp;
110 return stamp;
63111 }
64112
65113 /** How far behind the present this timeline is drawn, in seconds. */
66114 export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX);
67115
68 /** The moment being drawn, given the clock now. */
69 export const renderTime = (timeline, now) => now - delay(timeline);
116 /**
117 * The moment being drawn, given the clock now.
118 *
119 * Not simply `now - delay`: the delay follows the measured rate, and a delay
120 * that moves between frames moves the moment being drawn with it, so a
121 * frame after a late snapshot drew twelve milliseconds of motion and the
122 * next nineteen — the beat of the wire showing through as a stutter. The
123 * moment drawn now advances at real time, and creeps toward where the delay
124 * says it should be at no more than two percent of that: a speed nobody can
125 * see. Only a clock a quarter second off — a tab that was in the background,
126 * the first frame — is started again where it belongs.
127 */
128 export function renderTime(timeline, now) {
129 const want = now - delay(timeline);
130 const dt = now - timeline.drawnNow;
131 timeline.drawnNow = now;
132 const ahead = timeline.drawAt + dt;
133 if (timeline.drawAt === 0 || dt <= 0 || dt > RESYNC || Math.abs(want - ahead) > RESYNC) {
134 timeline.drawAt = want;
135 return want;
136 }
137 timeline.drawAt = ahead + clamp(want - ahead, -dt * SLEW, dt * SLEW);
138 return timeline.drawAt;
139 }
70140
71141 const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle });
72142
@@ -87,8 +157,13 @@ export function feed(timeline, id, body, at) {
87157 const last = track.marks[track.marks.length - 1];
88158 if (at <= last.t) return timeline;
89159 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;
160 // Two samples filed close together are still a tick apart on the server,
161 // so a body is allowed at least an interval's run between them.
162 const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * Math.max(gap, timeline.interval) + 4;
163 if (gap > GONE || jumped) {
164 track.marks.length = 0;
165 timeline.restarts += 1;
166 }
92167 track.marks.push(mark(at, body));
93168 if (track.marks.length > KEEP) track.marks.shift();
94169 return timeline;
static/predict.js +84 −12
@@ -23,8 +23,22 @@ export const SNAP = 40;
2323 export const PULL = 12;
2424 /** How far ahead of a snapshot your own input is ever carried, in seconds. */
2525 export const LEAD_MAX = 0.25;
26 /**
27 * How long your own keys take to show in the server's answer: a send every
28 * 50ms, a hop each way, a tick. The drawn body is allowed this much of a
29 * lead over the answer without being called wrong, in seconds of running —
30 * and a disagreement inside it is leaned off gently rather than at PULL,
31 * because most of what is inside it is not a disagreement at all but the
32 * lag itself, which a hard pull turns into a yank at every start and stop.
33 */
34 export const LEAD_SLACK = 0.08;
35 export const SOFT_PULL = 2;
2636
27 export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0 });
37 /**
38 * `anchored` counts the times the server's word was taken outright: every one
39 * is a jump. `still` is how long no key has been held, in seconds.
40 */
41 export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0, anchored: 0, still: 0 });
2842
2943 /** Take the server's word for it outright. */
3044 export function anchor(local, at) {
@@ -32,6 +46,7 @@ export function anchor(local, at) {
3246 local.y = at.y;
3347 local.ready = true;
3448 local.gap = 0;
49 local.anchored += 1;
3550 return local;
3651 }
3752
@@ -78,32 +93,59 @@ export function project(at, mx, my, speed, age, world) {
7893 * Lean the drawn body toward the server's answer. A small disagreement —
7994 * the width of the guess, a shot of lag — is walked off over a moment; a
8095 * large one is not a disagreement but a different place, and is taken.
96 *
97 * With keys held (`mx, my`), the part of the gap along the way you are going
98 * is the lag when the drawn body is ahead: the server has not heard the keys
99 * yet. Inside `slack` that is left exactly alone — pulling it is what made
100 * every start a yank and every stop a drift. Across the way you are going,
101 * or with the server ahead, a gap is real and is leaned off at PULL. Having
102 * just stopped, a gap inside the slack is the server still catching up to
103 * where you stopped: held for the lag, then leaned off gently enough not to
104 * show.
81105 */
82 export function reconcile(local, server, dt) {
106 export function reconcile(local, server, dt, mx = 0, my = 0, slack = 0) {
83107 if (!local.ready) return anchor(local, server);
84108 const dx = server.x - local.x;
85109 const dy = server.y - local.y;
86110 local.gap = Math.hypot(dx, dy);
87111 if (local.gap > SNAP) return anchor(local, server);
88 const k = 1 - Math.exp(-PULL * Math.max(0, dt));
89 local.x += dx * k;
90 local.y += dy * k;
112 const hard = 1 - Math.exp(-PULL * Math.max(0, dt));
113 const len = Math.hypot(mx, my);
114 if (len === 0) {
115 if (local.gap <= slack && local.still < LEAD_SLACK) return local;
116 const k = local.gap > slack ? hard : 1 - Math.exp(-SOFT_PULL * Math.max(0, dt));
117 local.x += dx * k;
118 local.y += dy * k;
119 return local;
120 }
121 const ux = mx / len;
122 const uy = my / len;
123 const along = dx * ux + dy * uy; // positive: the server is ahead of the drawing
124 const acrossX = dx - along * ux;
125 const acrossY = dy - along * uy;
126 local.x += acrossX * hard;
127 local.y += acrossY * hard;
128 if (along > 0 || -along > slack) {
129 local.x += ux * along * hard;
130 local.y += uy * along * hard;
131 }
91132 return local;
92133 }
93134
94135 /**
95 * Never past the answer. The guess may fill the gap to where the server's
96 * word and your own held keys put you and no further: left to run, a guess
97 * that moves faster than its target is corrected finds its own equilibrium
98 * somewhere beyond it, which is a claim about a position rather than a
99 * drawing of one.
136 * Never further past the answer than the lag. The guess may fill the gap to
137 * where the server's word and your own held keys put you, plus `slack` —
138 * the distance your keys travel before the server has heard them — and no
139 * further: left to run, a guess that moves faster than its target is
140 * corrected finds its own equilibrium somewhere beyond it, which is a claim
141 * about a position rather than a drawing of one.
100142 */
101 export function holdBack(local, target, mx, my) {
143 export function holdBack(local, target, mx, my, slack = 0) {
102144 const len = Math.hypot(mx, my);
103145 if (len === 0) return local;
104146 const dx = mx / len;
105147 const dy = my / len;
106 const over = (local.x - target.x) * dx + (local.y - target.y) * dy;
148 const over = (local.x - target.x) * dx + (local.y - target.y) * dy - slack;
107149 if (over > 0) {
108150 local.x -= dx * over;
109151 local.y -= dy * over;
@@ -113,3 +155,33 @@ export function holdBack(local, target, mx, my) {
113155
114156 /** A body drawn inside a wall is worse than a body drawn late. */
115157 export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile);
158
159 /**
160 * One frame of your own body, the whole of it: the server's answer `me`
161 * carried `age` seconds forward by the keys `held`, the drawn body leaned
162 * onto that, advanced a frame, and never drawn past it.
163 *
164 * A lean across a corner can put the drawn body inside a wall, and `refused`
165 * is the caller's word for anywhere else the server would not let you stand
166 * (the spawn pen in the buy phase). Both used to take the server's answer,
167 * which is a jump: pressed against a wall and strafing, that fired most
168 * frames and read as a stutter. Holding the last good place is invisible and
169 * just as honest — the next snapshot corrects it anyway. Anchoring is kept
170 * for the one case where holding still leaves the body in a wall.
171 */
172 export function stepLocal(local, me, held, speed, age, dt, world, refused) {
173 const target = project(me, held.mx, held.my, speed, age, world);
174 const wasX = local.x;
175 const wasY = local.y;
176 const slack = speed * LEAD_SLACK;
177 local.still = held.mx === 0 && held.my === 0 ? local.still + dt : 0;
178 reconcile(local, target, dt, held.mx, held.my, slack);
179 advance(local, held.mx, held.my, speed, dt, world);
180 holdBack(local, target, held.mx, held.my, slack);
181 if (insideWall(local, world) || (refused !== null && refused(local.x, local.y))) {
182 local.x = wasX;
183 local.y = wasY;
184 if (insideWall(local, world)) anchor(local, me);
185 }
186 return local;
187 }
static/val.js +50 −27
@@ -15,8 +15,8 @@ import { makeBeeper } from "./beep.js";
1515 import { makeBudget, measure } from "./budget.js";
1616 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js";
1717 import { BODY_R, moveSpeed } from "../src/glide.ts";
18 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
19 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
18 import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./gun.js";
19 import { makeLocal, stepLocal } from "./predict.js";
2020 import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js";
2121 import { choosePrompt, REACH } from "./prompt.js";
2222 import { renderSize, WIDTHS } from "./resolution.js";
@@ -110,22 +110,25 @@ function connect() {
110110 const data = JSON.parse(event.data);
111111 if (data.t !== "s") return;
112112 snap = data;
113 snapAt = performance.now();
113 const landed = performance.now();
114114 // No seat, no `me`, no players. Everything below reads a body out of the
115115 // snapshot, so a watching connection takes the other path entirely.
116116 if (watching(data)) return void paintWatch(data);
117117 // Timed samples for every body in it, ours included: the drawing walks
118 // between them rather than chasing the newest one.
119 arrive(timeline, snapAt / 1000);
118 // between them rather than chasing the newest one. The time a sample is
119 // filed under is the wire's even beat, not the moment this one happened
120 // to land — see interp.js. Your own snapshot's age is read off the same
121 // beat, so the lead your keys are carried by does not wobble with it.
122 snapAt = arrive(timeline, landed / 1000) * 1000;
120123 // Between rounds everybody is teleported to a spawn, so no sighting
121124 // before that says anything about where anybody is now.
122125 if (data.phase !== "live") forget(presence);
123 else sighted(presence, data.players, snapAt / 1000);
126 else sighted(presence, data.players, landed / 1000);
124127 feed(timeline, data.me.id, data.me, snapAt / 1000);
125128 for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000);
126129 // The server's angle is taken only when it is a correction, never as the
127130 // routine echo of the angle we sent it a moment ago.
128 settle(head, data.me, snapAt / 1000);
131 settle(head, data.me, landed / 1000);
129132 gunSnapshot(gun, data);
130133 paintHud(data);
131134 maybeHelp(data);
@@ -215,7 +218,12 @@ addEventListener("mousemove", (event) => {
215218 if (locked) turn(head, event.movementX * LOOK);
216219 });
217220 addEventListener("mousedown", (event) => {
218 if (locked && event.button === 0) firing = true;
221 if (locked && event.button === 0) {
222 firing = true;
223 // The gun kicks now, not when the server's word comes back: the shot is
224 // still the server's to fire and to land, this is the hand moving.
225 if (snap && !watching(snap)) gunFire(gun, snap);
226 }
219227 });
220228 addEventListener("mouseup", (event) => {
221229 if (locked && event.button === 0) firing = false;
@@ -251,6 +259,7 @@ view.addEventListener("pointerdown", (event) => {
251259 }
252260 pointer = canvasPoint(event);
253261 firing = true;
262 if (snap && !watching(snap)) gunFire(gun, snap);
254263 });
255264 view.addEventListener("pointermove", (event) => {
256265 if (stick && event.pointerId === stick.id) {
@@ -383,6 +392,20 @@ const outsidePen = (x, y) => {
383392 return best > 120;
384393 };
385394
395 /**
396 * A wall an ability put up is as solid as the map's, and the snapshot carries
397 * every one of them. Without this the guess ran into a barrier the map did
398 * not know about, and the drawn body stood inside it until the server's
399 * answer pulled it out.
400 */
401 const insideBarrier = (x, y) => {
402 for (const zone of snap.zones) if (zone.k === "wall" && zone.f <= 0 && Math.hypot(zone.x - x, zone.y - y) < zone.r + BODY_R) return true;
403 return false;
404 };
405
406 /** Anywhere the server would refuse to let you stand right now. */
407 const refused = (x, y) => outsidePen(x, y) || insideBarrier(x, y);
408
386409 /** The server's answer, caught up to now, then this frame of your own keys. */
387410 function predict(dt, now) {
388411 if (!predicting()) {
@@ -394,26 +417,18 @@ function predict(dt, now) {
394417 const speed = moveSpeed({
395418 haste: snap.me.haste > 0,
396419 slow: snap.me.slow > 0,
397 stopped: snap.me.planting > 0 || snap.me.defusing,
420 // Nobody moves once a round is decided: the server's tick returns before
421 // it moves anybody, and a guess that kept walking ran a quarter second
422 // into nothing and waited there for the next round to cut back.
423 stopped: snap.me.planting > 0 || snap.me.defusing || snap.phase === "end" || snap.phase === "over",
398424 heavy: snap.me.weapon === "operator",
399425 });
400426 // The snapshot is a moment old and the keys held since have not reached
401 // it; carry it that far forward and lean the drawn body onto it.
402 const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world);
403 const was = { x: local.x, y: local.y };
404 reconcile(local, target, dt);
405 advance(local, held.mx, held.my, speed, dt, world);
406 holdBack(local, target, held.mx, held.my);
407 // A lean across a corner can put the drawn body inside a wall, and walking
408 // out of the spawn pen is refused by the server outright. Both used to take
409 // the server's answer, which is a jump: pressed against a wall and strafing,
410 // that fired most frames and read as a stutter. Holding the last good place
411 // is invisible and just as honest — the next snapshot corrects it anyway.
412 if (insideWall(local, world) || outsidePen(local.x, local.y)) {
413 local.x = was.x;
414 local.y = was.y;
415 if (insideWall(local, world)) anchor(local, snap.me);
416 }
427 // it; carry it that far forward and lean the drawn body onto it. Walking
428 // out of the spawn pen, or into a barrier, is refused by the server
429 // outright, so it is refused here too — see predict.js for how a refusal
430 // holds rather than snaps.
431 stepLocal(local, snap.me, held, speed, (now - snapAt) / 1000, dt, world, refused);
417432 drawnMe.id = snap.me.id;
418433 drawnMe.x = local.x;
419434 drawnMe.y = local.y;
@@ -499,17 +514,25 @@ function togglePerf() {
499514 /**
500515 * The readout, rewritten twice a second: the frame rate and the mean frame
501516 * the governor decided on, the rate snapshots are actually arriving at, the
502 * interpolation delay in force, and how wide the world is being drawn.
517 * interpolation delay in force, how wide the world is being drawn, and how
518 * many times a minute the drawing took the server's word outright — your
519 * own body anchored, somebody else started again, the view re-pointed. Each
520 * of those is a jump, and a jump is what a stutter is made of.
503521 */
522 let perfSince = 0;
504523 function paintPerf(nowMs, force) {
505524 const box = $("perf");
506525 if (!perfOn || !box || (!force && nowMs - perfAt < 500)) return;
507526 perfAt = nowMs;
527 if (perfSince === 0) perfSince = nowMs;
508528 const hz = timeline.interval > 0 ? 1 / timeline.interval : 0;
529 const jumps = local.anchored + timeline.restarts + head.corrected;
530 const minutes = Math.max(1 / 60, (nowMs - perfSince) / 60000);
509531 box.textContent =
510532 `${budget.fps.toFixed(1)} fps · ${budget.mean.toFixed(1)} ms/frame · ` +
511533 `${hz.toFixed(1)} Hz in · ${(delay(timeline) * 1000).toFixed(0)} ms behind · ` +
512 `${render.width}×${render.height} of ${view.width}×${view.height}`;
534 `${render.width}×${render.height} of ${view.width}×${view.height} · ` +
535 `${jumps} jumps (${(jumps / minutes).toFixed(1)}/min)`;
513536 }
514537
515538 /**
static/yaw.js +3 −1
@@ -24,7 +24,8 @@ export const SENT_MEMORY = 1.2;
2424 /** How close an echo must be to count as ours. The server rounds to 0.1. */
2525 export const SENT_TOLERANCE = 0.08;
2626
27 export const makeYaw = (angle = 0) => ({ angle, id: "", alive: false, ready: false, sent: [] });
27 /** `corrected` counts the times the server's angle replaced ours: every one is a jump of the view. */
28 export const makeYaw = (angle = 0) => ({ angle, id: "", alive: false, ready: false, sent: [], corrected: 0 });
2829
2930 /** The short way round from one angle to another, in (-π, π]. */
3031 export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
@@ -65,6 +66,7 @@ export function settle(yaw, me, at) {
6566 const respawned = me.alive && !yaw.alive;
6667 const corrected = yaw.sent.length > 0 && !ours(yaw, me.angle, at);
6768 if (seatChanged || respawned || corrected) {
69 if (yaw.ready) yaw.corrected += 1;
6870 yaw.angle = me.angle;
6971 yaw.sent.length = 0;
7072 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts