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

forked from benjivers/val

workbenchthis machine

Draw your own body from a prediction, not from a snapshot

The client had no prediction: it drew the camera from an exponential smooth
toward the last snapshot at 15Hz, which is right for a body you only hear
about fifteen times a second and wrong for the one whose keys you are
holding. static/predict.js runs your own movement forward instead, through
src/glide.ts — the same step-and-slide the server resolves walls with, so
the guess lands where the answer is about to be rather than fighting it.

The server is still the only thing that says where you are. Each frame the
last snapshot is carried forward by the input held since it arrived (capped
at a quarter second, so a stalled socket cannot run off), the drawn body
leans onto that, and it is never drawn past it. Past 40 units of
disagreement the server's answer is simply taken, and the buy phase — whose
spawn pen is a rule of the match and not of the map — is not predicted at
all. Nothing about the guess is ever sent anywhere.

The snapshot gains one boolean for your own seat (`defusing`), so the
browser runs the prediction at the speed the server is about to use.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed cdce386 Browse files
6 files changed +430 −30
README.md +14 −1
@@ -37,7 +37,13 @@ server:
3737 `src/arena.ts` by comparing the players either side of a tick — it carries
3838 names, the gun and an age, and never a position.
3939 - **Bots** fill every empty seat and use their kit: smokes on the way in,
40 flashes before the entry, a recon dart, a molly on the planted spike.
40 flashes before the entry, a recon dart, a molly on the planted spike. They
41 shoot through the same spread everyone does, and they aim like something
42 with hands: 200–350ms to react to a target they have just seen, an aim
43 cone that opens several degrees on the swing and only settles while they
44 hold the same target, and more of both when you are crossing their view
45 (`src/aim.ts`). Every roll of it comes from the match's seeded random, so
46 a seed replays the same round.
4147
4248 Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits
4349 (charges last the round), a free signature on a cooldown, and an ultimate
@@ -47,6 +53,13 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
4753
4854 ## What is not
4955
56 - Your own body is *drawn* from a guess — the browser runs your own movement
57 forward from your own keys between snapshots, through the same
58 step-and-slide the server uses (`src/glide.ts`), and carries the last
59 snapshot forward by up to a quarter second of held input. It is a drawing
60 optimisation only: nothing is sent, every snapshot leans the drawn body
61 back onto the server's answer, and a real disagreement is taken outright.
62 Everybody else is still smoothed toward the last position the server sent.
5063 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
5164 no looking up or down, and everything that is not a wall — bodies, the
5265 spike, ability zones — is a flat billboard scaled by distance and clipped
src/arena.ts +3
@@ -264,6 +264,9 @@ export function snapshot(viewerId: string): string {
264264 haste: round2(me.haste),
265265 spike: me.spike,
266266 planting: round2(me.planting),
267 // Your own two ways of being held still, so the browser's prediction
268 // of your own body runs at the speed the server is about to use.
269 defusing: me.defusing > 0,
267270 bot: me.bot,
268271 },
269272 players: seen.map((p) => ({
src/client.test.ts +59 −3
@@ -21,6 +21,10 @@ const base = `http://${server.hostname}:${server.port}`;
2121 // ------------------------------------------------------------ the stub DOM
2222
2323 const drawn: string[] = [];
24 // Where the canvas was moved to: on the top-down view your own body is an
25 // arrow drawn about the origin, so the last translate of a frame is where
26 // this client thinks you are.
27 const moves: { x: number; y: number }[] = [];
2428 const listeners = new Map<string, ((event: unknown) => void)[]>();
2529 const frames: ((now: number) => void)[] = [];
2630
@@ -36,7 +40,10 @@ const context = () =>
3640 if (prop in target) return target[prop];
3741 if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
3842 if (prop === "globalAlpha" || prop === "lineWidth") return 1;
39 return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`);
43 return (...args: unknown[]) => {
44 if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) });
45 return drawn.push(`${prop}(${args.length})`);
46 };
4047 },
4148 set: () => true,
4249 },
@@ -97,6 +104,9 @@ const click = (node: Record<string, unknown>) => {
97104 const press = (code: string, key = "") => {
98105 for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
99106 };
107 const release = (code: string) => {
108 for (const fn of listeners.get("keyup") ?? []) fn({ code });
109 };
100110
101111 const nodes = new Map<string, Record<string, unknown>>();
102112 for (const id of [
@@ -160,9 +170,9 @@ globalThis.WebSocket = function (url: string) {
160170 return openedSocket;
161171 } as unknown as typeof WebSocket;
162172
163 const runFrame = () => {
173 const runFrame = (at = performance.now()) => {
164174 const pending = frames.splice(0, frames.length);
165 for (const fn of pending) fn(performance.now());
175 for (const fn of pending) fn(at);
166176 };
167177 const until = async (what: () => boolean, ms = 4000) => {
168178 const stop = Date.now() + ms;
@@ -274,6 +284,52 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
274284 // And the socket really took a seat in the running match.
275285 expect(seat.bot).toBe(false);
276286
287 // Your own body is drawn from a guess about your own movement, not from
288 // the last thing the server said. With the arena stopped — no snapshot can
289 // arrive at all — the drawn body still carries the keys you are holding
290 // forward, as far as the cap and no further, and stops there.
291 press("KeyV"); // the top-down view, where your own body is an arrow
292 for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
293 await Bun.sleep(60);
294 runFrame();
295 arena.stopArena();
296 // Walk whichever way is open from where the server last put us.
297 for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
298 const { blockedCircle } = await import("./map.ts");
299 const clear = ([
300 ["KeyW", 0, -1],
301 ["KeyS", 0, 1],
302 ["KeyA", -1, 0],
303 ["KeyD", 1, 0],
304 ] as const).find(([, dx, dy]) =>
305 [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
306 )!;
307 expect(clear).toBeDefined();
308 press(clear[0]);
309 const arrow = () => moves[moves.length - 1]!;
310 const startedAt = { x: seat.x, y: seat.y };
311 const scale = 900 / (30 * 80); // canvas pixels to a world unit
312 const t0 = performance.now();
313 for (let i = 1; i <= 10; i++) {
314 moves.length = 0;
315 runFrame(t0 + i * 40);
316 }
317 const drawnAt = arrow();
318 // The drawn body is ahead of the server's last word by the input held
319 // since it was sent — a quarter of a second of running at most, and then
320 // it waits, because past that a guess would be a claim. Smoothing toward
321 // a snapshot that cannot arrive would have gone nowhere at all.
322 const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
323 const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
324 expect(lead).toBeGreaterThan(30);
325 expect(lead).toBeLessThan(0.25 * 250 + 10);
326 expect(Math.abs(sideways)).toBeLessThan(6);
327 // Nothing on the server moved: this is a drawing, and the match is still
328 // the only thing that says where anybody is.
329 expect(seat.x).toBe(startedAt.x);
330 expect(seat.y).toBe(startedAt.y);
331 arena.startArena();
332
277333 // This test's loopback address is shared with every other test in the
278334 // run; close the socket so the address is free for the next real
279335 // connection rather than left holding a seat for the rest of the suite.
src/predict.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * The browser's guess about where you are, checked against the server's
3 * answer: the same maths, the same wall, and a disagreement that closes.
4 */
5
6 import { expect, test } from "bun:test";
7 import { BODY_R, moveSpeed } from "./glide.ts";
8 import { moveCircle, solidTile, TILE } from "./map.ts";
9 import { advance, anchor, holdBack, insideWall, LEAD_MAX, makeLocal, project, reconcile, SNAP } from "../static/predict.js";
10
11 // The world the browser builds out of map.json, which is the map the server
12 // plays on: the same rows, the same tile, the same body.
13 const world = { solid: solidTile, tile: TILE, r: BODY_R };
14
15 test("nothing is drawn until the server has said where you are", () => {
16 const local = makeLocal();
17 expect(local.ready).toBe(false);
18 advance(local, 1, 0, 250, 1 / 60, world);
19 expect(local).toMatchObject({ x: 0, y: 0 });
20 reconcile(local, { x: 1200, y: 640 }, 1 / 60);
21 expect(local).toMatchObject({ x: 1200, y: 640, ready: true });
22 });
23
24 test("a frame of movement is the speed it was given, in the direction it was given", () => {
25 const local = anchor(makeLocal(), { x: 1200, y: 640 });
26 advance(local, 1, 0, 250, 1 / 50, world);
27 expect(local.x).toBeCloseTo(1205, 6);
28 expect(local.y).toBeCloseTo(640, 6);
29 // A diagonal is a direction, not a speed boost.
30 const diagonal = anchor(makeLocal(), { x: 1200, y: 640 });
31 advance(diagonal, 1, 1, 250, 1 / 50, world);
32 expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6);
33 // Standing still, planted, or stopped to defuse: nothing moves.
34 const still = anchor(makeLocal(), { x: 1200, y: 640 });
35 advance(still, 0, 0, 250, 1 / 50, world);
36 advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world);
37 expect(still).toMatchObject({ x: 1200, y: 640 });
38 });
39
40 test("the guess resolves a wall exactly the way the server does", () => {
41 // Every open tile of the real map, walked into from eight directions: the
42 // browser's step and the server's land in the same place, or the camera
43 // spends the round being dragged off whatever it guessed.
44 const rows = 19;
45 const cols = 30;
46 let tried = 0;
47 for (let row = 1; row < rows - 1; row++)
48 for (let col = 1; col < cols - 1; col++) {
49 if (solidTile(col, row)) continue;
50 const x = col * TILE + TILE / 2;
51 const y = row * TILE + TILE / 2;
52 for (const [mx, my] of [
53 [1, 0],
54 [-1, 0],
55 [0, 1],
56 [0, -1],
57 [0.7, 0.7],
58 [-0.7, 0.7],
59 [0.7, -0.7],
60 [-0.7, -0.7],
61 ]) {
62 const local = anchor(makeLocal(), { x, y });
63 advance(local, mx, my, 250, 1 / 15, world);
64 const server = moveCircle(x, y, mx * 250 * (1 / 15), my * 250 * (1 / 15), BODY_R);
65 expect(local.x).toBeCloseTo(server.x, 9);
66 expect(local.y).toBeCloseTo(server.y, 9);
67 tried += 1;
68 }
69 }
70 expect(tried).toBeGreaterThan(1000);
71 });
72
73 test("a disagreement is leaned off, not jumped", () => {
74 const local = anchor(makeLocal(), { x: 1200, y: 640 });
75 reconcile(local, { x: 1210, y: 640 }, 1 / 60);
76 expect(local.gap).toBeCloseTo(10, 6);
77 // A frame moves a fraction of the way, never the whole way.
78 expect(local.x).toBeGreaterThan(1200);
79 expect(local.x).toBeLessThan(1203);
80 // Held there, it closes.
81 for (let i = 0; i < 60; i++) reconcile(local, { x: 1210, y: 640 }, 1 / 60);
82 expect(local.x).toBeCloseTo(1210, 2);
83 });
84
85 test("a real disagreement is taken outright: the server is the answer", () => {
86 const local = anchor(makeLocal(), { x: 1200, y: 640 });
87 // A spawn, a teleport, a seat handed to somebody else mid-round.
88 reconcile(local, { x: 1200 + SNAP + 1, y: 640 }, 1 / 60);
89 expect(local.x).toBe(1200 + SNAP + 1);
90 expect(local.gap).toBe(0);
91 });
92
93 test("a snapshot is carried forward by the input held since it was sent", () => {
94 const server = { x: 1200, y: 640 };
95 // Holding nothing, the answer is the answer: no lead, nothing invented.
96 expect(project(server, 0, 0, 250, 0.1, world)).toEqual({ x: 1200, y: 640 });
97 expect(project(server, 1, 0, 0, 0.1, world)).toEqual({ x: 1200, y: 640 });
98 expect(project(server, 1, 0, 250, 0, world)).toEqual({ x: 1200, y: 640 });
99 // Holding east for 66ms, it is 66ms of running east ahead.
100 const ahead = project(server, 1, 0, 250, 1 / 15, world);
101 expect(ahead.x - 1200).toBeCloseTo(250 / 15, 6);
102 // A connection that stopped talking is not a licence to run off the map.
103 const far = project(server, 1, 0, 250, 30, world);
104 expect(far.x - 1200).toBeLessThanOrEqual(250 * LEAD_MAX);
105 // And the carry is the server's own step-and-slide: never through a wall.
106 for (let y = 100; y < 1400; y += 37) {
107 const from = { x: 300, y };
108 if (insideWall(from, world)) continue; // not a place anybody can stand
109 expect(insideWall(project(from, -1, 0, 250, LEAD_MAX, world), world)).toBe(false);
110 }
111 });
112
113 test("the guess is never drawn past the answer it is running toward", () => {
114 const target = { x: 1300, y: 640 };
115 // Ahead of the target along the way it is going: pulled back onto it.
116 const past = anchor(makeLocal(), { x: 1320, y: 640 });
117 holdBack(past, target, 1, 0);
118 expect(past.x).toBeCloseTo(1300, 9);
119 // Short of it, or across it, or standing still: left alone.
120 const short = anchor(makeLocal(), { x: 1280, y: 648 });
121 holdBack(short, target, 1, 0);
122 expect(short).toMatchObject({ x: 1280, y: 648 });
123 const still = anchor(makeLocal(), { x: 1320, y: 640 });
124 holdBack(still, target, 0, 0);
125 expect(still.x).toBe(1320);
126 });
127
128 test("a guess running with the server stays within a stride of it, and never inside a wall", () => {
129 // Sixty frames a second guessing, fifteen a second corrected, walking east
130 // into the wall that bounds mid. This is the case that used to skip.
131 const speed = 250;
132 const start = { x: 1200, y: 640 };
133 let server = { ...start };
134 const local = anchor(makeLocal(), start);
135 let worst = 0;
136 let back = 0;
137 let previous = local.x;
138 for (let frame = 0; frame < 240; frame++) {
139 advance(local, 1, 0, speed, 1 / 60, world);
140 // The server ticks at 30Hz and is heard from at 15Hz.
141 if (frame % 2 === 0) server = moveCircle(server.x, server.y, speed / 30, 0, BODY_R);
142 if (frame % 4 === 0) reconcile(local, server, 1 / 15);
143 worst = Math.max(worst, Math.hypot(local.x - server.x, local.y - server.y));
144 // The drawn body never walks backwards: that is what a skip looks like.
145 back = Math.min(back, local.x - previous);
146 previous = local.x;
147 expect(insideWall(local, world)).toBe(false);
148 }
149 expect(worst).toBeLessThan(BODY_R);
150 expect(back).toBeGreaterThan(-0.01);
151 });
static/predict.js added +115
@@ -0,0 +1,115 @@
1 /**
2 * Where you are, drawn at the frame rate.
3 *
4 * Everybody else in a snapshot is smoothed toward the last position the
5 * server sent, which is right for a body you only hear about fifteen times a
6 * second. Doing it to your own body is what made the view skip: the camera
7 * was always being dragged toward a position taken sixty-six milliseconds
8 * ago, and against a wall the server kept correcting it besides.
9 *
10 * So your own body is run forward here instead, from your own keys, through
11 * the same step-and-slide the server uses (`src/glide.ts` — one copy, or the
12 * guess fights the answer at every wall). The server is still the only thing
13 * that decides where you are: every snapshot leans the drawn body back
14 * toward its answer, and a real disagreement is taken outright. This is a
15 * drawing optimisation and nothing here is ever sent anywhere.
16 */
17
18 import { circleBlocked, slideCircle } from "../src/glide.ts";
19
20 /** Past this much disagreement the server simply wins: a spawn, a teleport. */
21 export const SNAP = 40;
22 /** How fast the rest of a disagreement is leaned off, a second. */
23 export const PULL = 12;
24 /** How far ahead of a snapshot your own input is ever carried, in seconds. */
25 export const LEAD_MAX = 0.25;
26
27 export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0 });
28
29 /** Take the server's word for it outright. */
30 export function anchor(local, at) {
31 local.x = at.x;
32 local.y = at.y;
33 local.ready = true;
34 local.gap = 0;
35 return local;
36 }
37
38 /**
39 * One frame of your own movement: a direction, the speed the server will
40 * move you at, and the world to slide against — `{ solid, tile, r }`.
41 */
42 export function advance(local, mx, my, speed, dt, world) {
43 if (!local.ready) return local;
44 const len = Math.hypot(mx, my);
45 if (len > 1) {
46 mx /= len;
47 my /= len;
48 }
49 if (speed <= 0 || (mx === 0 && my === 0) || dt <= 0) return local;
50 const to = slideCircle(local.x, local.y, mx * speed * dt, my * speed * dt, world.r, world.solid, world.tile);
51 local.x = to.x;
52 local.y = to.y;
53 return local;
54 }
55
56 /**
57 * Where the server's last answer has got to by now.
58 *
59 * A snapshot is a position from a moment ago, and the input you have held
60 * since it was sent has not reached it yet. Rather than a sequence number
61 * the server echoes back, the unacknowledged part of your own movement is
62 * measured in time: the age of the snapshot, capped, walked through the
63 * same slide. Hold nothing and this is the server's answer exactly, which
64 * is the point — it is what the drawn body settles onto.
65 */
66 export function project(at, mx, my, speed, age, world) {
67 const len = Math.hypot(mx, my);
68 if (len > 1) {
69 mx /= len;
70 my /= len;
71 }
72 const ahead = Math.min(LEAD_MAX, Math.max(0, age));
73 if (speed <= 0 || ahead === 0 || (mx === 0 && my === 0)) return { x: at.x, y: at.y };
74 return slideCircle(at.x, at.y, mx * speed * ahead, my * speed * ahead, world.r, world.solid, world.tile);
75 }
76
77 /**
78 * Lean the drawn body toward the server's answer. A small disagreement —
79 * the width of the guess, a shot of lag — is walked off over a moment; a
80 * large one is not a disagreement but a different place, and is taken.
81 */
82 export function reconcile(local, server, dt) {
83 if (!local.ready) return anchor(local, server);
84 const dx = server.x - local.x;
85 const dy = server.y - local.y;
86 local.gap = Math.hypot(dx, dy);
87 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;
91 return local;
92 }
93
94 /**
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.
100 */
101 export function holdBack(local, target, mx, my) {
102 const len = Math.hypot(mx, my);
103 if (len === 0) return local;
104 const dx = mx / len;
105 const dy = my / len;
106 const over = (local.x - target.x) * dx + (local.y - target.y) * dy;
107 if (over > 0) {
108 local.x -= dx * over;
109 local.y -= dy * over;
110 }
111 return local;
112 }
113
114 /** A body drawn inside a wall is worse than a body drawn late. */
115 export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile);
static/val.js +88 −26
@@ -12,7 +12,9 @@
1212 "use strict";
1313
1414 import { makeBeeper } from "./beep.js";
15 import { BODY_R, moveSpeed } from "../src/glide.ts";
1516 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
17 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
1618 import { choosePrompt, REACH } from "./prompt.js";
1719 import { roster, rosterKey } from "./roster.js";
1820 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
@@ -30,6 +32,7 @@ const HELP_SEEN = "val.help.seen";
3032
3133 let map = null;
3234 let solid = () => true;
35 let world = null; // what predict.js slides against: the grid, from map.json
3336 let snap = null;
3437 let socket = null;
3538 let agents = null;
@@ -45,8 +48,17 @@ let stick = null; // { id, ox, oy, mx, my }
4548 let aimTouch = null;
4649 let helpShown = false;
4750 let last = performance.now();
51 let snapAt = last; // when the last snapshot landed: how stale the answer is
4852
4953 const walk = { x: 0, y: 0 };
54 // What this person is doing right now, read once a frame for the drawing and
55 // once every 50ms for the server. The same object either way: no allocation
56 // in a loop that runs sixty times a second.
57 const intent = { mx: 0, my: 0, angle: 0, fire: false, use: false, reload: false };
58 // Your own body, run forward from your own keys between snapshots, and the
59 // object the drawing is handed for it.
60 const local = makeLocal();
61 const drawnMe = { id: "", x: 0, y: 0, angle: 0 };
5062 const scene = makeScene();
5163 const gun = makeGun();
5264 const beeper = makeBeeper();
@@ -66,6 +78,7 @@ function connect() {
6678 if (data.t !== "s") return;
6779 const first = snap === null;
6880 snap = data;
81 snapAt = performance.now();
6982 if (first && mode === "fp") yaw = data.me.angle;
7083 gunSnapshot(gun, data);
7184 paintHud(data);
@@ -206,11 +219,10 @@ view.addEventListener("pointerup", endPointer);
206219 view.addEventListener("pointercancel", endPointer);
207220 view.addEventListener("contextmenu", (event) => event.preventDefault());
208221
209 // Twenty times a second the server hears what this person is doing. In the
210 // first-person view the keys are read against the way you are facing; in the
211 // top-down view they are the compass, the way they always were.
212 setInterval(() => {
213 if (!snap || !map) return;
222 // What this person is doing, in the world's terms. In the first-person view
223 // the keys are read against the way you are facing; in the top-down view they
224 // are the compass, the way they always were.
225 function readIntent() {
214226 let ax = 0;
215227 let ay = 0;
216228 if (keys.has("KeyW")) ay -= 1;
@@ -221,31 +233,31 @@ setInterval(() => {
221233 ax = stick.mx;
222234 ay = stick.my;
223235 }
224 let mx = ax;
225 let my = ay;
226 let angle;
236 intent.mx = ax;
237 intent.my = ay;
227238 if (mode === "fp") {
228 if (keys.has("ArrowLeft")) yaw -= 0.045;
229 if (keys.has("ArrowRight")) yaw += 0.045;
230 angle = yaw;
239 intent.angle = yaw;
231240 moveVector(ax, ay, yaw, walk);
232 mx = walk.x;
233 my = walk.y;
241 intent.mx = walk.x;
242 intent.my = walk.y;
234243 } else {
235244 const aim = aimTouch || pointer;
236 const me = worldToScreen(snap.me);
237 angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
238 yaw = angle;
245 const me = worldToScreen(drawnMe.id ? drawnMe : snap.me);
246 intent.angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
247 yaw = intent.angle;
239248 }
240 send({
241 t: "i",
242 mx,
243 my,
244 a: angle,
245 f: firing || keys.has("Space"),
246 u: keys.has("KeyF"),
247 r: keys.has("KeyR"),
248 });
249 intent.fire = firing || keys.has("Space");
250 intent.use = keys.has("KeyF");
251 intent.reload = keys.has("KeyR");
252 return intent;
253 }
254
255 // Twenty times a second the server hears it. Intent only — a direction, an
256 // angle, a held key — never a position: the guess below is for drawing.
257 setInterval(() => {
258 if (!snap || !map) return;
259 const now = readIntent();
260 send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload });
249261 }, 50);
250262
251263 // ------------------------------------------------------------------ drawing
@@ -275,7 +287,49 @@ function smooth(id, x, y, angle) {
275287 return now;
276288 }
277289
278 const look = (p) => smooth(p.id, p.x, p.y, p.angle);
290 /**
291 * Everybody else is smoothed toward the last position the server sent.
292 * You are not: your own body is the one this browser can run forward
293 * honestly, and smoothing it is what dragged the camera backwards.
294 */
295 const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle));
296
297 /**
298 * Your own movement is only guessed at while the round is live and you are
299 * alive. The buy phase pens everybody in their own spawn — a rule of the
300 * match, not of the map — and a guess that does not know it would spend the
301 * phase being pulled back through the door.
302 */
303 const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive;
304
305 /** The server's answer, caught up to now, then this frame of your own keys. */
306 function predict(dt, now) {
307 if (!predicting()) {
308 local.ready = false;
309 drawnMe.id = "";
310 return;
311 }
312 const held = readIntent();
313 const speed = moveSpeed({
314 haste: snap.me.haste > 0,
315 slow: snap.me.slow > 0,
316 stopped: snap.me.planting > 0 || snap.me.defusing,
317 heavy: snap.me.weapon === "operator",
318 });
319 // The snapshot is a moment old and the keys held since have not reached
320 // it; carry it that far forward and lean the drawn body onto it.
321 const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world);
322 reconcile(local, target, dt);
323 advance(local, held.mx, held.my, speed, dt, world);
324 holdBack(local, target, held.mx, held.my);
325 // A lean across a corner could put the drawn body in a wall; the server's
326 // answer is never wrong, so take it rather than draw from inside one.
327 if (insideWall(local, world)) anchor(local, snap.me);
328 drawnMe.id = snap.me.id;
329 drawnMe.x = local.x;
330 drawnMe.y = local.y;
331 drawnMe.angle = mode === "fp" ? yaw : snap.me.angle;
332 }
279333
280334 function frame(now) {
281335 requestAnimationFrame(frame);
@@ -289,6 +343,11 @@ function frame(now) {
289343 drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height });
290344 return;
291345 }
346 // Turning with the arrow keys belongs to the frame, at a rate a second,
347 // rather than to whatever the input timer happens to do.
348 if (mode === "fp" && keys.has("ArrowLeft")) yaw -= 0.9 * dt;
349 if (mode === "fp" && keys.has("ArrowRight")) yaw += 0.9 * dt;
350 predict(dt, now);
292351 beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000);
293352 const eye = look(snap.me);
294353 const facing = mode === "fp" ? yaw : eye.angle;
@@ -606,6 +665,9 @@ Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").the
606665 const cols = rows[0].length;
607666 solid = (col, row) =>
608667 col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#";
668 // The same grid the server collides against, so a guess about your own
669 // movement lands where the server's answer is about to.
670 world = { solid, tile: map.tile, r: BODY_R };
609671 $("stage").dataset.mode = mode;
610672 fillHelp();
611673 resize();
↑↓ navigate ↵ open esc close

Keyboard shortcuts