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

forked from benjivers/val

workbenchthis machine

Send the movement speed instead of letting the client re-derive it

The browser rebuilt the server's speed decision from four snapshot fields
— haste, slow, holding still to plant or defuse, the weight of an operator,
the phases where nobody moves — which was `speedOf` in src/match.ts written
twice. The copy is correct only until somebody adds a fifth reason, and then
the drawn body creeps away from the server's answer and is pulled back.

So the snapshot carries `me.speed`: the match resolves every reason it has
to speed a body up, slow it down or hold it still, and the client passes the
number straight into stepLocal. A rule that stops you arrives as a zero and
the guess stops with it, with no client-side knowledge of why. `speedOf` is
now the one place a fifth reason gets added, and it takes the state, so the
phases whose tick returns before it moves anybody (end, over) and the dead
are inside the rule rather than copied into the browser.

The speed goes to a tenth rather than a whole unit: measured over three
minutes of a jittery wire, a whole number draws a hasted walk half a unit a
second fast and the cap clips it back every frame — 1.97% per-frame spread
against 1.74% for the exact rule. A tenth matches the exact rule to two
decimals and costs at most two bytes.

The three fields that existed only to be re-derived — slow, haste, defusing
— are off the snapshot; nothing else read them. Net, a seat's snapshot is
about 23 bytes a tick smaller.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 2e870ef Browse files
8 files changed +230 −30
README.md +5 −1
@@ -65,7 +65,11 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
6565 - Your own body is *drawn* from a guess — the browser runs your own movement
6666 forward from your own keys between snapshots, through the same
6767 step-and-slide the server uses (`src/engine/glide.ts`), and carries the last
68 snapshot forward by up to a quarter second of held input. It is a drawing
68 snapshot forward by up to a quarter second of held input. How fast it runs
69 is the server's answer and not one the browser worked out: the snapshot
70 carries `me.speed`, so a haste, an operator, a plant or a decided round
71 reaches the drawing as a number — a rule that stops you arrives as a zero.
72 It is a drawing
6973 optimisation only: nothing is sent, every snapshot leans the drawn body
7074 back onto the server's answer, and a real disagreement is taken outright.
7175 The lag your own keys take to reach the server (a send every 50ms, a hop,
src/arena.test.ts +58
@@ -64,3 +64,61 @@ test("a snapshot stays small enough to send thirty times a second", () => {
6464 expect(bytes).toBeGreaterThan(200);
6565 expect(bytes).toBeLessThan(8000);
6666 });
67
68 test("the snapshot carries the speed the server will move you at, not the reasons for it", () => {
69 // The browser used to rebuild this rule from four fields and was right
70 // only until a fifth was added. What it gets now is the answer: every
71 // reason the match has to speed a body up, slow it down or hold it still,
72 // resolved here, in units a second, to a tenth like every other number in
73 // the snapshot.
74 for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1);
75 const state = match();
76 const me = state.players[0]!;
77 const was = { ...me };
78 const phase = state.phase;
79 const seen = () => JSON.parse(snapshot(me.id)).me as Record<string, unknown>;
80 const speed = () => seen().speed;
81
82 Object.assign(me, { alive: true, haste: 0, slow: 0, planting: 0, defusing: 0, weapon: "classic" });
83 expect(speed()).toBe(250);
84 me.weapon = "operator";
85 expect(speed()).toBe(215);
86 me.weapon = "classic";
87 me.haste = 2;
88 expect(speed()).toBe(337.5); // exactly what the tick will use
89 me.haste = 0;
90 me.slow = 2;
91 expect(speed()).toBe(137.5);
92 me.slow = 0;
93
94 // Every way the match holds a body still arrives as a zero, and the client
95 // is told none of them: a fifth one would need no change over there.
96 me.planting = 1;
97 expect(speed()).toBe(0);
98 me.planting = 0;
99 me.defusing = 1;
100 expect(speed()).toBe(0);
101 me.defusing = 0;
102 me.alive = false;
103 expect(speed()).toBe(0);
104 me.alive = true;
105 // Including the phases whose tick returns before it moves anybody.
106 for (const stopped of ["end", "over"] as const) {
107 state.phase = stopped;
108 expect(speed()).toBe(0);
109 }
110 state.phase = phase;
111 expect(speed()).toBe(250);
112
113 // The three fields that existed only to be re-derived are gone with it.
114 const me2 = seen();
115 expect(me2).not.toHaveProperty("haste");
116 expect(me2).not.toHaveProperty("slow");
117 expect(me2).not.toHaveProperty("defusing");
118 // What it costs the wire: a name and a number, once a seat a tick.
119 const field = `"speed":${speed()},`;
120 expect(snapshot(me.id)).toContain(field);
121 expect(field.length).toBeLessThanOrEqual(16);
122
123 Object.assign(me, was);
124 });
src/arena.ts +10 −6
@@ -17,7 +17,7 @@ import { recordResult } from "./db.ts";
1717 import { makeArena, type Command, type Why } from "./engine/arena.ts";
1818 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1919 import type { Point } from "./map.ts";
20 import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, tick, visible } from "./match.ts";
20 import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, speedOf, tick, visible } from "./match.ts";
2121 import type { MatchState, Player } from "./state.ts";
2222 import { isWeaponId, WEAPONS } from "./weapons.ts";
2323
@@ -142,13 +142,17 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) {
142142 ult: me.ult,
143143 ultNeeds: agent.abilities.x.points,
144144 blind: round2(me.blind),
145 slow: round2(me.slow),
146 haste: round2(me.haste),
147145 spike: me.spike,
148146 planting: round2(me.planting),
149 // Your own two ways of being held still, so the browser's prediction
150 // of your own body runs at the speed the server is about to use.
151 defusing: me.defusing > 0,
147 // How fast the server is about to move you, in units a second, and not
148 // the reasons behind it: the browser's prediction runs at this number
149 // and re-derives nothing. A rule that holds you still — planting,
150 // defusing, a decided round — arrives as a zero. To a tenth, like every
151 // other number here: rounded to a whole unit instead, a hasted walk
152 // (337.5) is drawn a half-unit a second fast and clipped back by the
153 // cap every frame, which measured 0.2 points of per-frame spread. A
154 // tenth is a no-op for feel and costs at most two bytes.
155 speed: round2(speedOf(state, me)),
152156 bot: me.bot,
153157 },
154158 players: seen.map((p) => ({
src/client.test.ts +86
@@ -525,3 +525,89 @@ test("the buy phase is predicted, and its pen is the server's own rule", async (
525525 expect(bundle).toContain("world, refused)");
526526 expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)");
527527 });
528
529 test("the drawn body runs at the speed the server sent, and never at one it works out", async () => {
530 // Two snapshots a client that re-derived the rule would read backwards: one
531 // that says stopped with every visible reason to be running, and one that
532 // says running with a visible reason to be stopped. The drawing follows the
533 // number both times, which is the whole of what it knows about movement.
534 const arena = await import("./arena.ts");
535 const { blockedCircle } = await import("./map.ts");
536 arena.stopArena();
537 const body = arena.match().players[0]!;
538 const base = JSON.parse(arena.snapshot(body.id));
539
540 // Somewhere with a hundred units of room in one direction to walk into.
541 const clear = (
542 [
543 ["KeyW", 0, -1],
544 ["KeyS", 0, 1],
545 ["KeyA", -1, 0],
546 ["KeyD", 1, 0],
547 ] as const
548 ).find(([, dx, dy]) => [25, 50, 75, 100].every((step) => !blockedCircle(body.x + dx * step, body.y + dy * step, 16)))!;
549 expect(clear).toBeDefined();
550
551 // The top-down view, where your own body is the last thing translated to.
552 if ((nodes.get("stage")!.dataset as Record<string, string>).mode !== "top") press("KeyV");
553 expect((nodes.get("stage")!.dataset as Record<string, string>).mode).toBe("top");
554 for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
555 press(clear[0]);
556
557 const mine = (base.players as Record<string, unknown>[]).find((p) => p.id === base.me.id) ?? {
558 id: base.me.id,
559 name: body.name,
560 agent: base.me.agent,
561 angle: 0,
562 hp: 100,
563 armour: 0,
564 mate: true,
565 spike: false,
566 blind: false,
567 revealed: false,
568 };
569 const scale = 900 / (30 * 80); // canvas pixels to a world unit, as above
570 const deliver = (me: Record<string, unknown>) => {
571 const snap = {
572 ...base,
573 phase: "live",
574 zones: [],
575 // Your own body is on the map as one of the bodies you can see, and it
576 // is the one the arrow is drawn for.
577 players: [{ ...mine, alive: true, x: body.x, y: body.y }],
578 shots: [],
579 me: { ...base.me, alive: true, x: body.x, y: body.y, planting: 0, ...me },
580 };
581 openedSocket!.onmessage!({ data: JSON.stringify(snap) } as MessageEvent);
582 };
583 /** How far past the server's own word the body is drawn, after ten frames. */
584 const leadOf = (me: Record<string, unknown>) => {
585 deliver(me);
586 const t0 = performance.now();
587 runFrame(t0); // the first frame after a jump takes the server's word
588 for (let i = 1; i <= 10; i++) {
589 moves.length = 0;
590 runFrame(t0 + i * 40);
591 }
592 const at = moves[moves.length - 1]!;
593 return (at.x / scale - body.x) * clear[1] + (at.y / scale - body.y) * clear[2];
594 };
595
596 // Stopped, with nothing in the snapshot to say why: no planting, no
597 // slow, a live round, a light weapon. A client working it out for itself
598 // would run this body the whole quarter second.
599 expect(leadOf({ speed: 0 })).toBeLessThan(1);
600 // And moving, mid-plant — a reason the old client read as stopped. The
601 // number is what counts, so the body walks.
602 const walking = leadOf({ speed: 250, planting: 1 });
603 expect(walking).toBeGreaterThan(30);
604 // And the number is the whole of it: half the speed, half the lead. The
605 // cap on a guess is a length of time (predict.js LEAD_MAX), so how far
606 // ahead the drawing may be is the server's speed and nothing else.
607 const half = leadOf({ speed: 125 });
608 expect(half / walking).toBeGreaterThan(0.45);
609 expect(half / walking).toBeLessThan(0.55);
610
611 release(clear[0]);
612 openedSocket?.close();
613 });
src/engine/glide.ts +4 −3
@@ -112,9 +112,10 @@ export function slideCircle(
112112 }
113113
114114 /**
115 * How fast a body moves, given what is on it. The browser reads the same
116 * four facts out of its snapshot that the match reads off a player, so a
117 * prediction runs at the speed the server is about to use.
115 * How fast a body moves, given what is on it. This is the server's alone:
116 * the game decides here (`speedOf` in the match), and the snapshot hands the
117 * browser the resulting number. Nothing in a client reads these four facts,
118 * so a fifth one can be added here without desyncing anybody's prediction.
118119 */
119120 export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number {
120121 if (state.stopped) return 0;
src/feel.test.ts +41 −6
@@ -11,9 +11,9 @@
1111 */
1212
1313 import { expect, test } from "bun:test";
14 import { BODY_R, moveSpeed, RUN } from "./engine/glide.ts";
14 import { BODY_R, RUN } from "./engine/glide.ts";
1515 import { solidTile, TILE } from "./map.ts";
16 import { newMatch, setInput, startRound, tick } from "./match.ts";
16 import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts";
1717 import { runBots } from "./bots.ts";
1818 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js";
1919 import { makeLocal, stepLocal } from "../static/engine/predict.js";
@@ -52,14 +52,19 @@ const spreadOf = (steps: number[]) => {
5252 };
5353
5454 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[] };
55 type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string; speed: number }; bodies: Body[] };
5656
5757 /**
5858 * A person walking east across open ground for two seconds, sending intent
5959 * every 50ms, hearing back thirty times a second over a jittery wire, and
6060 * drawn at sixty frames — with the input reaching the server one way late.
61 *
62 * The packet carries `speed` the way the arena's snapshot does — the match's
63 * own answer, to a tenth — and the drawing uses that and nothing else, which
64 * is what val.js does. `on` is whatever the walk is carrying: a haste, an
65 * operator, the states whose speed is not the plain run.
6166 */
62 function walkOwnBody(base = 0.02) {
67 function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) {
6368 const random = rng(7);
6469 const state = newMatch(11);
6570 const me = state.players[0]!;
@@ -76,6 +81,7 @@ function walkOwnBody(base = 0.02) {
7681 me.haste = 0;
7782 me.slow = 0;
7883 me.weapon = "classic";
84 Object.assign(me, on);
7985 const packets: Packet[] = [];
8086 const inputs: { at: number; mx: number; my: number }[] = [];
8187 const held = { mx: 0, my: 0 };
@@ -101,7 +107,15 @@ function walkOwnBody(base = 0.02) {
101107 packets.push({
102108 at: t + base + jitter(random),
103109 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 },
110 me: {
111 id: me.id,
112 x: round2(me.x),
113 y: round2(me.y),
114 angle: round2(me.angle),
115 alive: me.alive,
116 phase: state.phase,
117 speed: round2(speedOf(state, me)),
118 },
105119 bodies: [],
106120 });
107121 }
@@ -125,7 +139,9 @@ function walkOwnBody(base = 0.02) {
125139 if (!snap) continue;
126140 held.mx = now >= walkFrom && now < walkUntil ? 1 : 0;
127141 const before = local.anchored;
128 stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null);
142 // The speed the server sent, exactly as val.js passes it: no rule about
143 // movement is re-derived on this side of the wire.
144 stepLocal(local, snap.me, held, snap.me.speed, now - snap.stamp, 1 / 60, world, null);
129145 // The first word about where you are is taken outright: that one is not a jump.
130146 if (before > 0) anchors += local.anchored - before;
131147 if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) {
@@ -155,6 +171,25 @@ test("your own body, walking, takes even steps and never a backward one", () =>
155171 expect(anchors).toBe(0);
156172 });
157173
174 test("a hasted walk is drawn at the speed the wire carried, rounding and all", () => {
175 // Haste is the case where the match's own answer is not a whole number:
176 // 250 × 1.35 is 337.5, and the wire carries it to a tenth, like every other
177 // number in the snapshot. What is checked is that the drawing runs at the
178 // speed the server will use — no correction, no backward step, and no body
179 // drawn out past where the server is about to put it.
180 const { steps, back, anchors, local, server } = walkOwnBody(0.02, { haste: 10 });
181 const { mean, spread } = spreadOf(steps);
182 console.log(
183 `hasted body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` +
184 `largest backward step ${back.toFixed(2)}, anchors ${anchors}, drawn ${(local.x - server.x).toFixed(1)} past the server`,
185 );
186 expect(mean * 60).toBeGreaterThan(RUN * 1.35 * 0.9);
187 expect(mean * 60).toBeLessThan(RUN * 1.35 * 1.1);
188 expect(spread).toBeLessThan(0.05);
189 expect(back).toBeGreaterThan(-0.01);
190 expect(anchors).toBe(0);
191 });
192
158193 test("starting and stopping never pulls you back, and the speed never dips a third", () => {
159194 // The first tenth of a second after the key goes down the server has not
160195 // heard yet, and the guess used to be yanked back to the server's still
src/match.ts +18 −4
@@ -26,8 +26,10 @@ import {
2626 import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts";
2727 import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
2828
29 // A body's size and its speed are shared with the browser, which predicts
30 // your own movement between snapshots: `src/engine/glide.ts` is the one copy.
29 // A body's size and the step-and-slide are shared with the browser, which
30 // predicts your own movement between snapshots: `src/engine/glide.ts` is the
31 // one copy. How fast that body moves is decided here alone, by `speedOf`,
32 // and sent as a number — see the note on it.
3133 export const PLAYER_R = BODY_R;
3234 export const RUN_SPEED = RUN;
3335 export const BUY_TIME = 14;
@@ -359,7 +361,19 @@ export function tick(state: MatchState, dt: number): void {
359361 endConditions(state);
360362 }
361363
362 function speedOf(player: Player): number {
364 /**
365 * How fast this player moves right now — every reason the match has to speed
366 * a body up, slow it down or hold it still, in one number.
367 *
368 * The browser used to rebuild this from four snapshot fields, which was this
369 * function written twice and correct only until a fifth reason was added.
370 * The snapshot carries the answer instead (`me.speed` in `src/arena.ts`), so
371 * a new rule that stops you arrives as a zero and the prediction stops with
372 * it without knowing why. Add a reason here and nothing else needs telling.
373 */
374 export function speedOf(state: MatchState, player: Player): number {
375 // The phases whose tick returns before it moves anybody, and the dead.
376 if (state.phase === "end" || state.phase === "over" || !player.alive) return 0;
363377 return moveSpeed({
364378 haste: player.haste > 0,
365379 slow: player.slow > 0,
@@ -379,7 +393,7 @@ function movePlayer(state: MatchState, player: Player, dt: number, penned: boole
379393 mx /= len;
380394 my /= len;
381395 }
382 const speed = speedOf(player) * dt;
396 const speed = speedOf(state, player) * dt;
383397 if (speed === 0 || (mx === 0 && my === 0)) return;
384398 let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R);
385399 // In the buy phase nobody leaves their own spawn.
static/val.js +8 −10
@@ -15,7 +15,7 @@ import { banner, drawBanner } from "./banner.js";
1515 import { makeBeeper } from "./beep.js";
1616 import { makeBudget, measure } from "./engine/budget.js";
1717 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./engine/interp.js";
18 import { BODY_R, moveSpeed } from "../src/engine/glide.ts";
18 import { BODY_R } from "../src/engine/glide.ts";
1919 import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./engine/gun.js";
2020 import { makeLocal, stepLocal } from "./engine/predict.js";
2121 import { forget, ghosts, makePresence, shown, sighted, slide } from "./engine/presence.js";
@@ -418,15 +418,13 @@ function predict(dt, now) {
418418 return;
419419 }
420420 const held = readIntent();
421 const speed = moveSpeed({
422 haste: snap.me.haste > 0,
423 slow: snap.me.slow > 0,
424 // Nobody moves once a round is decided: the server's tick returns before
425 // it moves anybody, and a guess that kept walking ran a quarter second
426 // into nothing and waited there for the next round to cut back.
427 stopped: snap.me.planting > 0 || snap.me.defusing || snap.phase === "end" || snap.phase === "over",
428 heavy: snap.me.weapon === "operator",
429 });
421 // The speed the server is about to move you at, as it sent it. This was
422 // rebuilt here out of four snapshot fields — haste, slow, holding still to
423 // plant or defuse, the weight of an operator, the phases where nobody
424 // moves — which was the server's rule written twice and right only until
425 // somebody added a fifth. Now a rule that stops you arrives as a zero and
426 // the guess stops with it, with no knowledge here of why.
427 const speed = snap.me.speed;
430428 // The snapshot is a moment old and the keys held since have not reached
431429 // it; carry it that far forward and lean the drawn body onto it. Walking
432430 // out of the spawn pen, or into a barrier, is refused by the server
↑↓ navigate ↵ open esc close

Keyboard shortcuts