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

forked from trevato/cs

Your own body stops waiting for the network: your movement is predicted and reconciled against the server's word #383

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

vesper/cs-engine:vesper/316-your-own-body-stops-waiting-for-the-netw → main 3 commits fork

  • Merged. vesper/316-your-own-body-stops-waiting-for-the-netw was merged into main as c6da65f.

Review

Sign in to review this change.
13 files changed +852 −49
README.md +19 −6
@@ -1718,12 +1718,25 @@ What is real and what is not, today:
17181718 economy (the buy goes up with the input frame and is spent on the server), and
17191719 the caps — twenty rooms a process, ten people a room, an empty room forgotten
17201720 after ninety seconds (`src/rooms/hub.ts`).
1721 - Not yet: your own movement is **not** predicted, so it answers on the round
1722 trip rather than instantly; a room has no hit marker or damage arrow (both are
1723 effects recorded for one screen and a snapshot goes to everybody). Who is in a
1724 room is seen from inside it: there is no page that lists a room's people before
1725 you join, because a code is all a stranger would need to read it. Rooms live
1726 in this process only — a deploy ends them.
1721 - Real, and predicted: **your own movement.** Every frame of keys that goes up
1722 is also kept, and your body is moved by it at once through the engine's own
1723 mover on the game's own tuning and hull (`src/client/predict.ts`); each
1724 snapshot ends with the stamp of the last input the server applied to your
1725 seat, and your body is put back on the server's word with every newer input
1726 replayed on top. A correction under 32 units (a hull's width) is eased out
1727 over 100 ms, a bigger one snaps. A test steps a real room on a quarter-second
1728 round trip and holds the page's body to the server's within a hundredth of a
1729 unit at every tick; the replay costs about 0.06 ms a snapshot.
1730 - Still the server's word, and never predicted: shots, hits, damage, armour,
1731 ammunition, the weapon in your hands, money, the score and the round. Nobody
1732 else's body is predicted either — the other nine are drawn where the last
1733 snapshot put them, in 62 ms steps. Prediction is off while you are dead.
1734 - Not yet: other bodies are not interpolated between snapshots; a room has no
1735 hit marker or damage arrow (both are effects recorded for one screen and a
1736 snapshot goes to everybody). Who is in a room is seen from inside it: there
1737 is no page that lists a room's people before you join, because a code is all
1738 a stranger would need to read it. Rooms live in this process only — a deploy
1739 ends them.
17271740
17281741 ### Whose result a room's match is
17291742
src/budget.test.ts +47 −1
@@ -31,6 +31,10 @@
3131 * - `buildRangeScene` on a live run: the room, the plates standing in it and a
3232 * tracer still fading. The range's frame is a thirtieth of the shooter's and
3333 * is measured anyway, because a game with no ceiling is where the cost goes.
34 * - A snapshot reconciled in a room: your own body put back on the server's
35 * word and twenty ticks of your unanswered inputs replayed through the mover
36 * — a 250 ms round trip and a snapshot's wait — which a member's page does
37 * sixteen times a second on top of its frame.
3438 * - And a frame deliberately made dearer, which must fail the same ceiling: a
3539 * guard nothing can trip is not a guard, and the last test here proves this
3640 * one trips.
@@ -96,7 +100,7 @@
96100 */
97101
98102 import { expect, test } from "bun:test";
99 import { pushEffect, resetEffects, type Camera } from "./engine/index.ts";
103 import { pushEffect, resetEffects, type Camera, type Vec3 } from "./engine/index.ts";
100104 import { DE_FORGE } from "./client/map.ts";
101105 import { SHORT } from "./client/match.ts";
102106 import { buildScene, createScene } from "./client/scene.ts";
@@ -105,6 +109,7 @@ import { NO_INPUT, createWorld, update, type Effect, type World } from "./client
105109 import { buildRangeScene, createRangeScene } from "./range/scene.ts";
106110 import { EYE, createRun, start, step } from "./range/targets.ts";
107111 import { createRoom, stepRoom, type Room } from "./rooms/room.ts";
112 import { createPrediction, keep, reconcile, TICK as PREDICT_TICK } from "./client/predict.ts";
108113
109114 /** The fixed simulation step, and the wall clock a frame of it has to fit in. */
110115 const TICK = 1 / 64;
@@ -124,6 +129,16 @@ const RANGE_SCENE_CEILING = 0.02;
124129 */
125130 const ROOM_SECOND_CEILING = 40;
126131
132 /**
133 * A room member's own body replayed over a 250 ms round trip and the snapshot
134 * interval on top of it — twenty ticks of the mover — which is what every
135 * snapshot that lands costs a page sixteen times a second. Today: 0.063 ms, so
136 * the ceiling is eight times that and still a thirtieth of a frame.
137 */
138 const REPLAY_CEILING = 0.5;
139 /** How many ticks that replay is: 250 ms of 64 Hz, plus one 62.5 ms snapshot wait. */
140 const REPLAY_TICKS = Math.round((250 + 62.5) / (1000 * PREDICT_TICK));
141
127142 /** Batches thrown away before any are kept, so nothing measured is cold. */
128143 const WARM = 2;
129144 /** Batches kept, of which the median is the answer. Odd, so there is one. */
@@ -431,3 +446,34 @@ test("the range's scene is built in a fraction of what a tick costs", () => {
431446 expect(scene.instanceCount).toBe(1 + run.targets.length);
432447 expect(scene.lineCount).toBe(2);
433448 }, PATIENCE);
449
450 /**
451 * What prediction costs a page in a room: a snapshot lands, your body goes back
452 * to the server's word, and every input it has not answered is played through
453 * the mover again. Twenty of them is a 250 ms round trip with a snapshot's wait
454 * on top, on de_forge's boxes, strafing and turning so the walls are met. It is
455 * measured here so a slower mover, or a history that forgot its cap, cannot
456 * quietly become the frame's problem.
457 */
458 test("replaying a 250 ms round trip of your own inputs is a small slice of a frame", () => {
459 const world = liveRound(7);
460 const me = world.me;
461 const own = { pos: [...me.pos] as Vec3, vel: [0, 0, 0] as Vec3 };
462 const runs = 200;
463 let replayed = 0;
464 const each = measure(`a snapshot reconciled: ${REPLAY_TICKS} ticks of your own inputs replayed`, REPLAY_CEILING, () => {
465 const p = createPrediction();
466 reconcile(p, own, 0, world, me, 0);
467 for (let t = 1; t <= REPLAY_TICKS; t++)
468 keep(p, { tick: t, input: { ...NO_INPUT, forward: 1, side: t % 2 ? 1 : -1 }, yaw: t * 9, pitch: 0, switchCode: 0, buy: null }, 1, world, me);
469 return timed(runs, () => {
470 for (let i = 0; i < runs; i++) reconcile(p, own, 0, world, me, i);
471 replayed = p.kept.length;
472 });
473 });
474
475 expect(each).toBeLessThan(REPLAY_CEILING);
476 // And it really was twenty ticks each time: nothing acknowledged, nothing dropped.
477 expect(replayed).toBe(REPLAY_TICKS);
478 expect(REPLAY_TICKS).toBe(20);
479 }, PATIENCE);
src/client/main.ts +12 −6
@@ -84,7 +84,7 @@ import { matchFrom, playKey } from "../matchid.ts";
8484 import { pastedLink } from "../matchlink.ts";
8585 import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts";
8686 import type { Situation } from "../plan/situation.ts";
87 import { DE_FORGE } from "./map.ts";
87 import { DE_FORGE, levelNamed } from "./map.ts";
8888 import type { Level } from "./level.ts";
8989 import { FOV, fovFor, lookAt } from "./look.ts";
9090 import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts";
@@ -1270,7 +1270,9 @@ export function boot(host: Host): Frame {
12701270 code,
12711271 now: () => win.performance.now(),
12721272 build: (hello) => {
1273 const level = hello.map === "de_forge" ? DE_FORGE : generateLevel(seedOf(hello.map) ?? 0);
1273 // The server's own builder (`levelNamed`), so the walls this page
1274 // predicts your body against are the walls the room steps it against.
1275 const level = levelNamed(hello.map);
12741276 if (level !== shown && renderer !== null) {
12751277 renderer.replace("level", meshFor("level", level));
12761278 shown = level;
@@ -1631,14 +1633,18 @@ export function boot(host: Host): Frame {
16311633 // of spawn while you buy is the point.
16321634 if (session !== null) {
16331635 // A room's round is stepped on the server: what goes up is one frame of
1634 // keys for the tick the loop just counted — coalesced into one message
1635 // however many ticks this frame was worth, because a tick the page never
1636 // simulated has no separate keys to send. The buy is asked for once.
1636 // keys for the ticks the loop just counted — coalesced into one message
1637 // however many ticks this frame was worth, since they are the same keys
1638 // — and your body is moved by all of them here at once (`predict.ts`).
1639 // The buy is asked for once.
16371640 const held = buyPanel.hidden ? input : { ...input, fire: false, switchTo: null };
16381641 if (steps > 0) {
1639 session.press({ input: held, yaw: me.yaw, pitch: me.pitch, switchCode, buy: roomBuy });
1642 session.press({ input: held, yaw: me.yaw, pitch: me.pitch, switchCode, buy: roomBuy }, steps);
16401643 roomBuy = null;
16411644 }
1645 // Your predicted body, with what is left of the last correction eased in,
1646 // before the camera reads where you are.
1647 session.place(now);
16421648 } else {
16431649 for (let step = 0; step < steps; step++)
16441650 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false, switchTo: null });
src/client/map.ts +11
@@ -24,6 +24,7 @@
2424 import * as g from "../engine/index.ts";
2525 import { CELL, levelOf, type Cell } from "./cells.ts";
2626 import type { Level } from "./level.ts";
27 import { generateLevel, seedOf } from "./generate.ts";
2728
2829 /** De_forge is 44 cells on a side: 5,632 Source units, a little under dust's. */
2930 export const GRID_W = 44;
@@ -81,3 +82,13 @@ const grid: g.Grid<Cell> = build();
8182 * page they are written on and nothing else.
8283 */
8384 export const DE_FORGE: Level = levelOf("de_forge", grid);
85
86 /**
87 * The level a map name stands for: de_forge, or the one a generated name carves.
88 * A room's server and a member's page both build their level through here —
89 * `levelOf` in `src/rooms/room.ts` and the `hello` handler in `main.ts` — because
90 * a page that predicts its own movement is only right while it walks into the
91 * same walls the server does, and one function is the cheapest way to keep two
92 * builds of a level from ever being two different levels.
93 */
94 export const levelNamed = (map: string): Level => (map === "de_forge" ? DE_FORGE : generateLevel(seedOf(map) ?? 0));
src/client/predict.test.ts added +324
@@ -0,0 +1,324 @@
1 /**
2 * Prediction, held to the room it predicts. The first test is the one that
3 * matters: a real `Room` stepped headless on a scripted three seconds of keys —
4 * running, strafing while turning, a jump, a crouch, a walk, a switch to the
5 * knife — with a quarter of a second of round trip between it and a page, and
6 * the page's predictor fed the snapshots that room wrote. At every snapshot the
7 * correction is under `EPSILON`, and at every tick the body the page drew is
8 * where the room put it once that input arrived. That is the claim that the
9 * page runs the server's physics and not a second, subtly different one.
10 *
11 * The rest are the rules around it, on the same real frames: the snapshot's
12 * word on everything that is not movement, off while dead and before a frame
13 * says what it answers, other bodies untouched, the history capped, and a
14 * correction eased under `SNAP_UNITS` and snapped over it.
15 */
16
17 import { expect, test } from "bun:test";
18 import {
19 applySnapshot, readInput, writeInput, writeSnapshot, SNAPSHOT_MAX, SWITCH_KNIFE, SWITCH_NONE,
20 type Pressed, type Snapshot,
21 } from "../rooms/wire.ts";
22 import { createRoom, drive, helloFor, join, snapshotFor, stepRoom, type Room } from "../rooms/room.ts";
23 import type { Vec3 } from "../engine/index.ts";
24 import { nameOf } from "./generate.ts";
25 import { levelNamed } from "./map.ts";
26 import { COMPETITIVE, SHORT } from "./match.ts";
27 import {
28 createPrediction, keep, place, reconcile, shown, EASE_MS, HISTORY_TICKS, SNAP_UNITS,
29 type Prediction,
30 } from "./predict.ts";
31 import { useShipped } from "./shipped.ts";
32 import { createWorld, NO_INPUT, type Input, type Player, type World } from "./world.ts";
33
34 useShipped(); // the room's bots think, as they do in a deployed room
35
36 /**
37 * How close the page's body has to be to the room's, in units. Positions cross
38 * the wire as `f32`, which on a map a few thousand units across is a few
39 * ten-thousandths of a unit; a hundredth is that with room to spare and still
40 * a fiftieth of a pixel at any distance a body is drawn from its own eye.
41 */
42 const EPSILON = 0.01;
43
44 /** Ticks each way between page and room: 8 up and 8 down is a 250 ms round trip. */
45 const LATENCY = 8;
46
47 /** A room on de_forge, one member seated, the freeze set aside. */
48 function seated(map: "de_forge" | string = "de_forge"): { room: Room; id: string; seat: number } {
49 const room = createRoom("PRDICT", { map: map as "de_forge", seed: 9, length: "short", now: 0 });
50 room.world.match.phase = "live";
51 room.world.match.timer = room.rules.roundTime;
52 const member = join(room, "member", "you", 0);
53 if (member === null) throw new Error("the room had no seat");
54 return { room, id: member.id, seat: member.seat };
55 }
56
57 /** The page's world, built the way `main.ts` builds it from `hello`. */
58 function page(room: Room, seat: number): World {
59 const hello = helloFor(room, room.members.get("member") as NonNullable<ReturnType<typeof room.members.get>>);
60 const world = createWorld(levelNamed(hello.map), hello.rules === "long" ? COMPETITIVE : SHORT, "you", "ct", 5, hello.seed);
61 world.me = world.players[seat] as Player;
62 world.me.bot = false;
63 return world;
64 }
65
66 /** Tick `t` of the script: what the hand is doing. */
67 function script(t: number): { input: Input; yaw: number; switchCode: number } {
68 const input: Input = { ...NO_INPUT };
69 let yaw = 90;
70 if (t < 40) input.forward = 1;
71 else if (t < 90) {
72 input.forward = 1;
73 input.side = 1;
74 yaw = 90 + (t - 40) * 1.7; // strafing while turning
75 } else if (t < 110) {
76 input.forward = 1;
77 yaw = 175;
78 input.jump = t === 95;
79 } else if (t < 140) {
80 input.forward = -1;
81 input.duck = true;
82 yaw = 175;
83 } else if (t < 170) {
84 input.side = -1;
85 input.walk = true;
86 yaw = 200;
87 } else if (t < 185) {
88 yaw = 200; // hands off: friction brings the body to rest
89 } else {
90 input.forward = 0.6; // a stick, half forward
91 yaw = 260;
92 }
93 return { input, yaw, switchCode: t === 150 ? SWITCH_KNIFE : SWITCH_NONE };
94 }
95
96 const TICKS = 192;
97
98 const copy = (v: Vec3): Vec3 => [v[0], v[1], v[2]];
99 const far = (a: Vec3, b: Vec3): number => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
100
101 test("the page's body is the room's body, at every snapshot and every tick", () => {
102 const { room, id, seat } = seated();
103 const world = page(room, seat);
104 const p = createPrediction();
105 const up: { at: number; bytes: Uint8Array }[] = [];
106 const down: { at: number; bytes: Uint8Array }[] = [];
107 /** Where the page drew the body after pressing tick k, while predicting. */
108 const drawn = new Map<number, Vec3>();
109 /** Where the room had the body after stepping the input stamped k. */
110 const truth = new Map<number, Vec3>();
111 const corrections: number[] = [];
112
113 // The page presses on every tick of the run, as a page does on every frame:
114 // one that fell silent would be a room holding its last keys for
115 // `INPUT_HOLD` ticks while the page stood still, which is a stall and not
116 // physics. Only the ticks the room has stepped by the end are compared.
117 for (let t = 1; t <= TICKS + 3 * LATENCY; t++) {
118 // The page: one frame of keys, kept as the room will read it, and moved by.
119 {
120 const s = script(t);
121 const bytes = writeInput({ tick: t, input: s.input, yaw: s.yaw, pitch: 0, switchCode: s.switchCode, buy: null });
122 const sent = readInput(bytes) as Pressed;
123 keep(p, sent, 1, world, world.me);
124 if (p.on) drawn.set(t, copy(p.body.pos));
125 up.push({ at: t + LATENCY, bytes: new Uint8Array(bytes) });
126 }
127 // The room: whatever has arrived, one step, and a snapshot every fourth.
128 while (up.length > 0 && (up[0] as { at: number }).at <= t) drive(room, id, (up.shift() as { bytes: Uint8Array }).bytes);
129 stepRoom(room);
130 const body = room.world.players[seat] as Player;
131 const applied = room.applied.get(seat);
132 if (applied !== undefined && !truth.has(applied)) truth.set(applied, copy(body.pos));
133 if (room.tick % 4 === 0) down.push({ at: t + LATENCY, bytes: new Uint8Array(snapshotFor(room, room.members.get(id) as never)) });
134 // The page again: whatever snapshots have come back.
135 while (down.length > 0 && (down[0] as { at: number }).at <= t) {
136 const got = applySnapshot(world, (down.shift() as { bytes: Uint8Array }).bytes, seat) as Snapshot;
137 const wasOn = p.on;
138 const error = reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, t * 15.625);
139 if (wasOn) corrections.push(error);
140 }
141 // Nothing shot the body: the one thing a replay cannot know about is a hit.
142 expect(body.health).toBe(100);
143 expect(body.tag).toBe(0);
144 }
145
146 // The script really went somewhere, and really was predicted.
147 expect(drawn.size).toBeGreaterThan(TICKS - 4 * LATENCY);
148 expect(corrections.length).toBeGreaterThan(40);
149 const start = truth.get(1) as Vec3;
150 const end = truth.get(TICKS) as Vec3;
151 expect(far(start, end)).toBeGreaterThan(200);
152
153 let worst = 0;
154 let compared = 0;
155 for (const [k, at] of drawn) {
156 const was = truth.get(k);
157 if (was === undefined) continue;
158 compared += 1;
159 worst = Math.max(worst, far(at, was as Vec3));
160 }
161 expect(compared).toBeGreaterThan(TICKS);
162 const most = Math.max(...corrections);
163 console.log(` prediction against the room: worst tick ${worst.toExponential(2)} u, worst correction ${most.toExponential(2)} u, over ${compared} ticks and ${corrections.length} snapshots`);
164 expect(worst).toBeLessThan(EPSILON);
165 expect(most).toBeLessThan(EPSILON);
166 });
167
168 test("the level a page builds from hello is the level the room stands on", () => {
169 // Asserted rather than assumed: a replay against other walls is other physics.
170 for (const map of ["de_forge", nameOf(31)]) {
171 const room = createRoom("LEVELS", { map: map as "de_forge", seed: 3, length: "short", now: 0 });
172 const member = join(room, "m", "you", 0) as NonNullable<ReturnType<typeof join>>;
173 const hello = helloFor(room, member);
174 expect(levelNamed(hello.map).boxes).toEqual(room.level.boxes);
175 }
176 });
177
178 /** A seated room stepped a second with the member holding W, and the page's world after one snapshot. */
179 function played(): { room: Room; id: string; seat: number; world: World; p: Prediction; now: number } {
180 const { room, id, seat } = seated();
181 const world = page(room, seat);
182 const p = createPrediction();
183 let now = 0;
184 for (let t = 1; t <= 64; t++) {
185 const bytes = writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null });
186 keep(p, readInput(bytes) as Pressed, 1, world, world.me);
187 drive(room, id, bytes);
188 stepRoom(room);
189 now += 15.625;
190 }
191 const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot;
192 reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now);
193 return { room, id, seat, world, p, now };
194 }
195
196 test("health, armour, ammunition, weapon and score are the snapshot's, and the replay never touches them", () => {
197 const { room, id, seat, world, p, now } = played();
198 const body = room.world.players[seat] as Player;
199 // The server's word changes on everything that is not movement.
200 body.health = 61;
201 body.armor = 44;
202 body.weapon = "knife";
203 body.ammo.knife = { mag: 0, reserve: 0 };
204 body.kills = 3;
205 body.money = 2750;
206 room.world.match.score = { t: 2, ct: 5 };
207 const own = body.secondary;
208 body.ammo[own] = { mag: 7, reserve: 24 };
209 const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot;
210 // A few inputs the room has not seen, replayed on top of that snapshot and
211 // stepped again after it: the replay has every chance to write something.
212 for (let t = 65; t <= 70; t++) {
213 const bytes = writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: SWITCH_KNIFE, buy: null });
214 keep(p, readInput(bytes) as Pressed, 1, world, world.me);
215 }
216 reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now);
217 place(p, world.me, now + 5);
218 const me = world.me;
219 expect(me.health).toBe(61);
220 expect(me.armor).toBe(44);
221 expect(me.weapon).toBe("knife");
222 expect(me.kills).toBe(3);
223 expect(me.money).toBe(2750);
224 expect(world.match.score).toEqual({ t: 2, ct: 5 });
225 expect(me.ammo.knife).toEqual({ mag: 0, reserve: 0 });
226 // And the position is the prediction's, six ticks past the server's.
227 expect(p.on).toBe(true);
228 expect(far(me.pos, got.own?.pos as Vec3)).toBeGreaterThan(5);
229 });
230
231 test("inputs the snapshot answers are dropped, and the ones after it kept", () => {
232 const { room, id, seat, world, p, now } = played();
233 for (let t = 65; t <= 72; t++)
234 keep(p, readInput(writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null })) as Pressed, 1, world, world.me);
235 // The room has seen up to 68.
236 for (let t = 65; t <= 68; t++) {
237 drive(room, id, writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }));
238 stepRoom(room);
239 }
240 const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot;
241 expect(got.ack).toBe(68);
242 const error = reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now);
243 expect(p.kept.map((k) => k.pressed.tick)).toEqual([69, 70, 71, 72]);
244 expect(error).toBeLessThan(EPSILON);
245 });
246
247 test("the history holds the ticks a worst-case round trip covers, and no more", () => {
248 const { world, p } = played();
249 for (let t = 100; t < 100 + HISTORY_TICKS * 3; t++)
250 keep(p, { tick: t, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me);
251 expect(p.kept.length).toBe(HISTORY_TICKS);
252 expect((p.kept[0] as { pressed: Pressed }).pressed.tick).toBe(100 + HISTORY_TICKS * 2);
253 // A frame worth two ticks counts as two of them.
254 keep(p, { tick: 999, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 2, world, world.me);
255 expect(p.kept.reduce((n, k) => n + k.steps, 0)).toBeLessThanOrEqual(HISTORY_TICKS);
256 expect(HISTORY_TICKS).toBe(36);
257 });
258
259 test("prediction is off before a frame says what it answers, and while dead", () => {
260 const { room, id, seat } = seated();
261 const world = page(room, seat);
262 const p = createPrediction();
263 const before = copy(world.me.pos);
264 keep(p, { tick: 1, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me);
265 // Before any snapshot nothing moves: there is nothing to predict from.
266 expect(p.on).toBe(false);
267 place(p, world.me, 0);
268 expect(world.me.pos).toEqual(before);
269
270 // A frame from a server that does not send the acknowledgement: taken, not replayed onto.
271 const bare = writeSnapshot(room.world, room.tick, 0, new Uint8Array(SNAPSHOT_MAX));
272 const got = applySnapshot(world, bare, seat) as Snapshot;
273 expect(got.ack).toBe(null);
274 reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, 0);
275 expect(p.on).toBe(false);
276 expect(p.kept).toEqual([]);
277
278 // Dead: the server's position, however many keys are held.
279 const body = room.world.players[seat] as Player;
280 body.alive = false;
281 body.health = 0;
282 const dead = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot;
283 keep(p, { tick: 2, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me);
284 reconcile(p, dead.own as NonNullable<Snapshot["own"]>, dead.ack, world, world.me, 0);
285 expect(p.on).toBe(false);
286 keep(p, { tick: 3, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me);
287 expect(p.body.pos).toEqual(dead.own?.pos as Vec3);
288 });
289
290 test("every body that is not yours is the snapshot's, prediction or not", () => {
291 const { room, id, seat, world, p, now } = played();
292 for (let t = 65; t <= 70; t++)
293 keep(p, readInput(writeInput({ tick: t, input: { ...NO_INPUT, side: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null })) as Pressed, 1, world, world.me);
294 const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot;
295 reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now);
296 place(p, world.me, now);
297 room.world.players.forEach((q, i) => {
298 if (i === seat) return;
299 const mine = world.players[i] as Player;
300 expect(far(mine.pos, q.pos)).toBeLessThan(EPSILON);
301 });
302 });
303
304 test("a correction under the threshold is eased out over EASE_MS, one over it snaps", () => {
305 const { world, p, now } = played();
306 const at = copy(p.body.pos);
307 const own = { pos: copy(at), vel: [0, 0, 0] as Vec3 };
308 // The server says ten units east of where the page had you.
309 own.pos[0] += 10;
310 const error = reconcile(p, own, 64 + HISTORY_TICKS, world, world.me, now);
311 expect(error).toBeCloseTo(10, 5);
312 // On the frame of the correction you are still drawn where you were…
313 expect(far(shown(p, now), at)).toBeLessThan(1e-9);
314 // …halfway through the ease, halfway there…
315 expect(shown(p, now + EASE_MS / 2)[0]).toBeCloseTo(at[0] + 5, 5);
316 // …and at the end of it, exactly on the server's word.
317 expect(shown(p, now + EASE_MS)).toEqual(own.pos);
318
319 // A correction wider than the body is not a stumble, and is not eased.
320 const later = now + 1000;
321 const jump = { pos: [own.pos[0] + SNAP_UNITS + 1, own.pos[1], own.pos[2]] as Vec3, vel: [0, 0, 0] as Vec3 };
322 expect(reconcile(p, jump, 64 + HISTORY_TICKS, world, world.me, later)).toBeGreaterThan(SNAP_UNITS);
323 expect(shown(p, later)).toEqual(jump.pos);
324 });
src/client/predict.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * Your own body, ahead of the network: the inputs you have sent and the server
3 * has not answered yet, and the replay that moves you by them.
4 *
5 * In a room your position is the server's word, and the server's word arrives
6 * a round trip after your hand moved. So the page keeps every input it sends,
7 * stamped the way it went up, and steps your body by it at once; when a
8 * snapshot arrives it says which of those inputs it is the result of (`ack` in
9 * `src/rooms/wire.ts`), the body is put back where the server has it, and every
10 * input the server has not seen yet is played again on top. Done right, the
11 * replay lands exactly where the page already had you and nothing moves.
12 *
13 * "Done right" is the whole of this file, and it is three things:
14 *
15 * - **The same mover.** One tick is `move` from the engine on `CS_MOVE` and
16 * `CS_HULL`, handed the wish `wishOf` in `src/client/world.ts` builds — the
17 * function the server's own `applyInput` builds it with — against the level
18 * the page built from `hello` through the server's own builder.
19 * - **The same input.** What is kept is not what the page meant to send: it is
20 * the frame *as the server reads it*, the bytes `writeInput` wrote put back
21 * through `readInput`, so the yaw is the sixteen-bit yaw and a stick's
22 * forward is the seven-bit forward the room steps with.
23 * - **Movement only.** Where you stand, how fast, whether you are on the floor
24 * and how far down you are crouched. Health, armour, ammunition, the weapon,
25 * money and score are the snapshot's and nothing in here writes them; a shot
26 * or a hit is never predicted, because a page that predicts damage is a page
27 * that lies to its player.
28 *
29 * What it does not know, and so gets wrong until the next snapshot corrects it:
30 * the slow a hit puts on you (`tag`, which does not cross the wire), a round
31 * that starts or ends inside the round trip, and a server that held your last
32 * input over a tick your next one was late for. Each of those is a correction,
33 * and a correction is eased or snapped by the two numbers below.
34 *
35 * Nothing in here is a socket or a browser: a prediction is plain data, and
36 * every function takes it and the world and hands back numbers, so
37 * `predict.test.ts` steps a real room and this side by side with neither.
38 */
39
40 import { move, type Body, type Vec3 } from "../engine/index.ts";
41 import { resolveSwitch, type Pressed } from "../rooms/wire.ts";
42 import { CS_HULL, CS_MOVE } from "./movement.ts";
43 import { wishOf, type Player, type World } from "./world.ts";
44 import type { WeaponId } from "./weapons.ts";
45
46 /** The fixed step both ends move a body by: the game's own 64 Hz. */
47 export const TICK = 1 / 64;
48
49 /**
50 * The worst round trip prediction is built to cover, in milliseconds: the
51 * point at which the page already says the connection is behind (`BEHIND_MS`
52 * in `session.ts`). Past it the page is honest about lagging, and pretending to
53 * hide it would only make the snap at the end of it bigger.
54 */
55 export const WORST_TRIP_MS = 500;
56
57 /**
58 * How many ticks of input are kept: the worst round trip, plus the sixteenth of
59 * a second a snapshot can wait on the server before it is sent (`SNAPSHOT_HZ`).
60 * 36 ticks. An input older than that is one the server has either answered or
61 * lost, and dropping it costs a correction and nothing else.
62 */
63 export const HISTORY_TICKS = Math.ceil((WORST_TRIP_MS + 1000 / 16) / (1000 * TICK));
64
65 /**
66 * Under this many units of error the correction is eased out; over it the body
67 * snaps in one frame. 32 is the width of the hull. A miss smaller than your own
68 * body is a held key a tick too long or a hit's slow the page did not know about
69 * — a stumble, which easing hides — and one wider than you is a spawn, a
70 * teleport or a connection that stalled: you are somewhere else, and gliding
71 * there would draw you through whatever stands in between.
72 */
73 export const SNAP_UNITS = 32;
74
75 /**
76 * How long an eased correction takes, in milliseconds. 100 is a little over
77 * one and a half snapshots (62.5 ms apart): long enough that a correction is a
78 * drift rather than a jump at 60 fps, short enough that it is done before the
79 * snapshot after next can land a second one on top of it.
80 */
81 export const EASE_MS = 100;
82
83 /** One input the server has not answered yet, and how many ticks the page moved by it. */
84 export type Kept = { pressed: Pressed; steps: number };
85
86 /** Your body as the replay has it, the inputs it has not been answered for, and the correction still showing. */
87 export type Prediction = {
88 /** Whether the body below is a prediction at all. False before an acknowledged snapshot, and while dead. */
89 on: boolean;
90 /** Where the replay has your body: the server's last word plus every input since. */
91 body: Body;
92 /** The inputs sent and not acknowledged, oldest first. */
93 kept: Kept[];
94 /** The part of the last correction not yet eased out, in units. */
95 offset: Vec3;
96 /** When that correction was made, on the page's clock in ms. */
97 since: number;
98 /** The weapon the replay ended holding, which a switch inside the round trip may have changed. */
99 held: WeaponId | null;
100 };
101
102 export const createPrediction = (): Prediction => ({
103 on: false,
104 body: { pos: [0, 0, 0], vel: [0, 0, 0], onGround: false, ducking: false, duckAmount: 0 },
105 kept: [],
106 offset: [0, 0, 0],
107 since: 0,
108 held: null,
109 });
110
111 /**
112 * One tick of your body under one input: the server's `applyInput`, movement
113 * only. The weapon in your hands is carried separately because a switch in the
114 * input changes the speed you run at on the tick it is pressed, as it does on
115 * the server, and the body's own `weapon` is the snapshot's and not touched.
116 */
117 function stepOnce(body: Body, pressed: Pressed, world: World, me: Player, held: WeaponId): WeaponId {
118 // What the server resolves the switch against is the body's own holster, and
119 // `resolveSwitch` is the server's function for it.
120 const wanted = resolveSwitch(pressed.switchCode, me);
121 const hands = wanted ?? held;
122 // The slow of a hit is the server's and is not sent, so the replay runs at
123 // the speed of a body nobody has shot: a correction if it has been.
124 const wish = wishOf(world, { yaw: pressed.yaw, scoped: me.scoped, tag: 0, weapon: hands }, pressed.input);
125 move(body, wish, TICK, world.level.boxes, CS_MOVE, CS_HULL);
126 return hands;
127 }
128
129 /**
130 * An input went up: keep it, and — while predicting — move by it now. `steps`
131 * is how many 64 Hz ticks of the page's loop it covers, because a frame that
132 * was worth two ticks sends one frame of keys and the server holds it for both.
133 * The history is capped at `HISTORY_TICKS` of those, oldest dropped first.
134 */
135 export function keep(p: Prediction, pressed: Pressed, steps: number, world: World, me: Player): void {
136 const ticks = Math.max(1, Math.min(HISTORY_TICKS, Math.floor(steps)));
137 p.kept.push({ pressed, steps: ticks });
138 let total = 0;
139 for (const k of p.kept) total += k.steps;
140 while (total > HISTORY_TICKS && p.kept.length > 0) total -= (p.kept.shift() as Kept).steps;
141 if (!p.on) return;
142 let held = p.held ?? me.weapon;
143 for (let i = 0; i < ticks; i++) held = stepOnce(p.body, pressed, world, me, held);
144 p.held = held;
145 }
146
147 /** Where the body is drawn at `now`: the replay's position plus what is left of the last correction. */
148 export function shown(p: Prediction, now: number): Vec3 {
149 const left = Math.max(0, 1 - (now - p.since) / EASE_MS);
150 return [p.body.pos[0] + p.offset[0] * left, p.body.pos[1] + p.offset[1] * left, p.body.pos[2] + p.offset[2] * left];
151 }
152
153 /**
154 * A snapshot arrived. `own` is where the server has your body, `ack` which of
155 * your inputs that is the result of, and `me` your body in the world the
156 * snapshot was just read into — its floor contact, its crouch, its weapon and
157 * whether it is alive are the snapshot's. The body goes back to the server's
158 * word, every input after `ack` is played again, and the difference between
159 * where you were drawn and where the replay puts you is eased or snapped.
160 *
161 * Returns how far the correction was, in units: 0 is a prediction that was
162 * exactly right, which is what `predict.test.ts` holds a room to.
163 */
164 export function reconcile(
165 p: Prediction, own: { pos: Vec3; vel: Vec3 }, ack: number | null, world: World, me: Player, now: number,
166 ): number {
167 const was = p.on ? shown(p, now) : null;
168 p.body.pos = [own.pos[0], own.pos[1], own.pos[2]];
169 p.body.vel = [own.vel[0], own.vel[1], own.vel[2]];
170 p.body.onGround = me.onGround;
171 p.body.ducking = me.ducking;
172 p.body.duckAmount = me.duckAmount;
173 // Off where it would lie: a dead body is not moved by anybody's keys, and a
174 // frame that does not say which input it answers cannot be replayed onto.
175 if (!me.alive || ack === null) {
176 p.on = false;
177 p.kept.length = 0;
178 p.offset = [0, 0, 0];
179 p.held = null;
180 return 0;
181 }
182 let drop = 0;
183 while (drop < p.kept.length && (p.kept[drop] as Kept).pressed.tick <= ack) drop += 1;
184 if (drop > 0) p.kept.splice(0, drop);
185 let held = me.weapon;
186 for (const k of p.kept) for (let i = 0; i < k.steps; i++) held = stepOnce(p.body, k.pressed, world, me, held);
187 p.held = held;
188 p.on = true;
189 if (was === null) {
190 p.offset = [0, 0, 0];
191 return 0;
192 }
193 const off: Vec3 = [was[0] - p.body.pos[0], was[1] - p.body.pos[1], was[2] - p.body.pos[2]];
194 const error = Math.hypot(off[0], off[1], off[2]);
195 p.offset = error > SNAP_UNITS ? [0, 0, 0] : off;
196 p.since = now;
197 return error;
198 }
199
200 /**
201 * The predicted body onto your own `Player`, for the frame to draw: the eased
202 * position, the replay's velocity, floor contact and crouch — and nothing else.
203 * A prediction that is off writes nothing, and the body keeps the server's.
204 */
205 export function place(p: Prediction, me: Player, now: number): void {
206 if (!p.on) return;
207 me.pos = shown(p, now);
208 me.vel = [p.body.vel[0], p.body.vel[1], p.body.vel[2]];
209 me.onGround = p.body.onGround;
210 me.ducking = p.body.ducking;
211 me.duckAmount = p.body.duckAmount;
212 }
src/client/session.test.ts +74
@@ -628,3 +628,77 @@ test("keys pressed after close go nowhere", () => {
628628 s.session.press(pressed({ input: { ...NO_INPUT, fire: true } }));
629629 expect(s.sent).toHaveLength(1);
630630 });
631
632 // --- your own body, predicted ------------------------------------------------
633
634 /** A snapshot that says which of our inputs it answers, the way a room writes one. */
635 const answered = (world: World, ack: number, tick = 1): Uint8Array =>
636 writeSnapshot(world, tick, 0, new Uint8Array(SNAPSHOT_MAX), ack);
637
638 test("your body moves on the frame you press, not a round trip later", () => {
639 const s = stage();
640 s.join();
641 const room = tenBodies("room");
642 room.match.phase = "live";
643 s.say(answered(room, 0));
644 const world = worldOf(s);
645 const from = [...body(room, SEAT).pos];
646 // Ten ticks of W looking along +x, and no snapshot back yet.
647 for (let i = 0; i < 5; i++) s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 }, yaw: 0 }), 2);
648 s.session.place(s.clock);
649 expect(s.session.prediction().on).toBe(true);
650 expect(s.session.prediction().kept.map((k) => k.steps)).toEqual([2, 2, 2, 2, 2]);
651 expect(world.me.pos[0] - (from[0] as number)).toBeGreaterThan(10);
652 expect(Math.abs(world.me.pos[1] - (from[1] as number))).toBeLessThan(0.01);
653 // Nobody else moved: the other nine are the snapshot's.
654 expect(body(world, 0).pos).toEqual(body(room, 0).pos);
655 });
656
657 test("a snapshot that answers every input puts you back on the server's word", () => {
658 const s = stage();
659 s.join();
660 const room = tenBodies("room");
661 room.match.phase = "live";
662 s.say(answered(room, 0));
663 for (let i = 0; i < 3; i++) s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } }));
664 // The server answered all three and says you did not move (a wall, say): the
665 // couple of units the page had you over is under `SNAP_UNITS`, so it is eased…
666 body(room, SEAT).health = 57;
667 s.say(answered(room, 3, 2));
668 const world = worldOf(s);
669 expect(s.session.prediction().kept).toEqual([]);
670 expect(world.me.health).toBe(57);
671 expect(world.me.pos[0]).toBeGreaterThan((body(room, SEAT).pos[0]) + 1);
672 // …and a tenth of a second later you are exactly where the server has you.
673 s.clock += 100;
674 s.session.place(s.clock);
675 expect(world.me.pos).toEqual(body(room, SEAT).pos);
676 });
677
678 test("a frame that answers nothing is taken as it is, and nothing is predicted", () => {
679 const s = stage();
680 s.join();
681 const room = tenBodies("room");
682 move(room);
683 s.say(answered(room, 0));
684 s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } }));
685 // A server on the previous deploy writes no acknowledgement.
686 s.say(snapshotOf(room, 2));
687 s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } }));
688 s.session.place(s.clock);
689 expect(s.session.prediction().on).toBe(false);
690 expect(worldOf(s).me.pos).toEqual([292, 104, 64]);
691 });
692
693 test("a dead body is the server's, whatever keys are held", () => {
694 const s = stage();
695 s.join();
696 const room = tenBodies("room");
697 move(room);
698 body(room, SEAT).alive = false;
699 s.say(answered(room, 0));
700 s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } }));
701 s.session.place(s.clock);
702 expect(s.session.prediction().on).toBe(false);
703 expect(worldOf(s).me.pos).toEqual([292, 104, 64]);
704 });
src/client/session.ts +56 −14
@@ -7,15 +7,18 @@
77 * (`src/rooms/room.ts`) and this fills a local world from the snapshots that
88 * arrive, so everything already built on a `World` — the scene, the HUD, the
99 * minimap, the sounds, the spectator camera — draws a room without knowing it
10 * is one. One thing is the browser's own even so: the view angles, which the
11 * frame writes on `world.me` and sends upstream, so aim answers the hand rather
12 * than the network. Your body's position is the server's word, arriving 16
13 * times a second — which is honest and is also the lag a player feels in a room
14 * today. Predicting your own movement is #305's remaining half and is
15 * deliberately not faked here.
10 * is one. Two things are the browser's own even so. One is the view angles,
11 * which the frame writes on `world.me` and sends upstream, so aim answers the
12 * hand rather than the network. The other is your body's movement: every input that goes
13 * up is also kept and stepped here at once through `src/client/predict.ts`, and
14 * each snapshot — which says which of those inputs it answers — puts the body
15 * back on the server's word and plays the rest again on top. Movement only:
16 * health, armour, ammunition, the weapon and the score are the snapshot's, and
17 * a dead body, a body that is not yours and a page before `hello` are not
18 * predicted at all.
1619 *
17 * Nothing between two snapshots is smoothed either: a snapshot is written
18 * straight onto the bodies, so every one of them moves in 62 ms steps and stands
20 * Nobody else's body is smoothed between two snapshots: a snapshot is written
21 * straight onto the other bodies, so every one of them moves in 62 ms steps and stands
1922 * still in between. Interpolating them is its own issue and is not pretended to
2023 * here or anywhere else.
2124 *
@@ -32,7 +35,8 @@
3235
3336 import { createMember, type MemberSocket } from "../engine/index.ts";
3437 import type { Down, Hello, Pressed, Snapshot } from "../rooms/wire.ts";
35 import { applySnapshot, forget, readDown, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts";
38 import { applySnapshot, forget, readDown, readInput, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts";
39 import { createPrediction, keep, place, reconcile, type Prediction } from "./predict.ts";
3640 import type { Player, World } from "./world.ts";
3741
3842 /** All this file asks a WebSocket to be — the engine's, under the name this game has always used. */
@@ -65,8 +69,19 @@ export type Session = {
6569 /** Which body is ours, or −1 before `hello`. */
6670 seat(): number;
6771 standing(now: number): Standing;
68 /** One tick of keys upstream. Dropped silently while the socket is not open. */
69 press(pressed: Omit<Pressed, "tick">): void;
72 /**
73 * One frame of keys upstream, covering `steps` ticks of the page's loop (one
74 * when not said), and your body moved by it at once. Dropped silently — sent
75 * nowhere and not predicted — while the socket is not open.
76 */
77 press(pressed: Omit<Pressed, "tick">, steps?: number): void;
78 /**
79 * Your body where this frame draws it: the prediction, with whatever is left
80 * of the last correction eased in. Nothing while prediction is off.
81 */
82 place(now: number): void;
83 /** The prediction, for the tests and the pull's numbers to read. */
84 prediction(): Prediction;
7085 close(): void;
7186 };
7287
@@ -101,6 +116,9 @@ export function createSession(options: {
101116 }): Session {
102117 let seat = -1;
103118 let people = 0;
119 let prediction = createPrediction();
120 /** How many ticks the frame being pressed covers, read by `writeInput` below. */
121 let steps = 1;
104122 let here: Here[] = [];
105123 let side: "t" | "ct" | null = null;
106124
@@ -115,11 +133,21 @@ export function createSession(options: {
115133 // A world only ever exists with a seat, so `seat` is ours by the time a
116134 // snapshot is read.
117135 readSnapshot: (world, bytes) => applySnapshot(world, bytes, seat),
118 writeInput: (pressed, tick, into) => writeInput({ ...pressed, tick }, into),
136 // The input the replay moves by is the frame as the room will read it —
137 // these bytes, put back through the room's own `readInput` — so a yaw is
138 // the sixteen-bit yaw on both ends and never the page's float.
139 writeInput: (pressed, tick, into) => {
140 const bytes = writeInput({ ...pressed, tick }, into);
141 const world = member.world();
142 const sent = readInput(bytes);
143 if (world !== null && sent !== null) keep(prediction, sent, steps, world, world.me);
144 return bytes;
145 },
119146 },
120147 hear(down, world) {
121148 if (down.k === "hello") {
122149 seat = down.seat;
150 prediction = createPrediction();
123151 const built = options.build(down);
124152 // A body nobody else is holding is a bot to everything that reads the
125153 // world; ours is not, and it is the one the camera and the HUD follow.
@@ -143,8 +171,13 @@ export function createSession(options: {
143171 },
144172 // Our own body: the server's position, handed over rather than written in
145173 // (`wire.ts`), so this is the one place a page decides what to do with it.
174 // It is reconciled against; while prediction is off it is simply taken.
146175 landed(got, world) {
147 if (got.own !== null) {
176 if (got.own === null) return;
177 reconcile(prediction, got.own, got.ack, world, world.me, options.now());
178 if (prediction.on) {
179 place(prediction, world.me, options.now());
180 } else {
148181 world.me.pos = got.own.pos;
149182 world.me.vel = got.own.vel;
150183 }
@@ -178,7 +211,16 @@ export function createSession(options: {
178211 code: options.code, live: member.live(), behind: member.behind(now), line: member.line(now), people,
179212 here, side,
180213 }),
181 press: member.press,
214 press(pressed, frames = 1) {
215 steps = frames;
216 member.press(pressed);
217 steps = 1;
218 },
219 place(now) {
220 const world = member.world();
221 if (world !== null) place(prediction, world.me, now);
222 },
223 prediction: () => prediction,
182224 close: member.close,
183225 };
184226 }
src/client/world.ts +25 −11
@@ -499,7 +499,7 @@ function checkRoundEnd(world: World): void {
499499
500500 // --- shooting -------------------------------------------------------------
501501
502 export function currentWeapon(p: Player): Weapon {
502 export function currentWeapon(p: Pick<Player, "weapon">): Weapon {
503503 return WEAPONS[p.weapon] ?? WEAPONS.knife;
504504 }
505505
@@ -814,9 +814,31 @@ function land(world: World, p: Player, speed: number): void {
814814 if (p.health <= 0) onKill(world, p, p, "fall", false);
815815 }
816816
817 /**
818 * What a body asks the mover for on one tick: the keys, gated by the round's
819 * phase, the view it is looking along, and the speed its weapon and its wounds
820 * allow. It is its own function because two places build it and they must build
821 * the same one: `applyInput` below, on this world's own tick, and the replay in
822 * `src/client/predict.ts`, which moves a member's own body ahead of the server
823 * and is only honest while its wish is this wish.
824 */
825 export function wishOf(
826 world: Pick<World, "match">, p: Pick<Player, "yaw" | "scoped" | "tag" | "weapon">, input: Input,
827 ): Wish {
828 const frozen = world.match.phase === "freeze" || world.match.phase === "over";
829 return {
830 forward: frozen ? 0 : input.forward,
831 side: frozen ? 0 : input.side,
832 yaw: p.yaw,
833 jump: input.jump && !frozen,
834 duck: input.duck,
835 walk: input.walk || p.scoped,
836 maxSpeed: Math.min(CS_MOVE.maxSpeed, currentWeapon(p).carrySpeed) * (1 - p.tag),
837 };
838 }
839
817840 export function applyInput(world: World, p: Player, input: Input, dt: number): void {
818841 if (!p.alive) return;
819 const frozen = world.match.phase === "freeze" || world.match.phase === "over";
820842 if (input.switchTo !== null && (input.switchTo === "knife" || input.switchTo === p.primary || input.switchTo === p.secondary)) {
821843 if (p.weapon !== input.switchTo) {
822844 p.weapon = input.switchTo;
@@ -838,15 +860,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
838860 }
839861 }
840862 const w = currentWeapon(p);
841 const wish: Wish = {
842 forward: frozen ? 0 : input.forward,
843 side: frozen ? 0 : input.side,
844 yaw: p.yaw,
845 jump: input.jump && !frozen,
846 duck: input.duck,
847 walk: input.walk || p.scoped,
848 maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed) * (1 - p.tag),
849 };
863 const wish = wishOf(world, p, input);
850864 const landed = move(p, wish, dt, world.level.boxes, CS_MOVE, CS_HULL);
851865 if (landed > 0) land(world, p, landed);
852866 if (!p.alive) return;
src/rooms/room.test.ts +19 −2
@@ -16,11 +16,11 @@ import {
1616 drive, freeSeat, isOver, join, leave, logSince, resultsOf, rosterOf, sideFrom, snapshotFor, stepRoom, uniqueName,
1717 INPUT_HOLD, TEAM_SIZE, type Finished, type Member, type Room,
1818 } from "./room.ts";
19 import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts";
19 import { applySnapshot, writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts";
2020 // The cap is the hub's, because the hub is what enforces it on a socket; a
2121 // frame of that size is refused here too, and this is where that is read.
2222 import { MESSAGE_MAX } from "./hub.ts";
23 import { ENDING_TICKS, forward, lastRound, room } from "./stage.test-helper.ts";
23 import { ENDING_TICKS, forward, lastRound, room, watcher } from "./stage.test-helper.ts";
2424 import { matchOf } from "../matchid.ts";
2525 import { parseResult, type Result } from "../result.ts";
2626 import { NO_INPUT, type Player } from "../client/world.ts";
@@ -295,3 +295,20 @@ test("a member who left before match_over has no result, and the bot that finish
295295 for (let i = 0; i < ENDING_TICKS; i++) stepRoom(r);
296296 expect(resultsOf(r).map((f) => f.id)).toEqual(["stays"]);
297297 });
298
299 test("a member's snapshot answers the newest of their inputs the room stepped with", () => {
300 const r = room();
301 const member = join(r, "a", "ada", 0) as NonNullable<ReturnType<typeof join>>;
302 // Nothing stepped yet: the answer is none of them, which is zero.
303 expect(applySnapshot(watcher(r), snapshotFor(r, member), member.seat)?.ack).toBe(0);
304 drive(r, "a", forward(7, 0));
305 // Arrived and not yet stepped with is not answered.
306 expect(applySnapshot(watcher(r), snapshotFor(r, member), member.seat)?.ack).toBe(0);
307 stepRoom(r);
308 drive(r, "a", forward(8, 0));
309 stepRoom(r);
310 expect(applySnapshot(watcher(r), snapshotFor(r, member), member.seat)?.ack).toBe(8);
311 // And leaving forgets it, so the next person in the seat starts from none.
312 leave(r, "a", 0);
313 expect(r.applied.has(member.seat)).toBe(false);
314 });
src/rooms/room.ts +13 −6
@@ -47,12 +47,11 @@
4747 * whole round with five members in it and no network at all.
4848 */
4949
50 import { DE_FORGE } from "../client/map.ts";
51 import { generateLevel } from "../client/generate.ts";
50 import { levelNamed } from "../client/map.ts";
5251 import { buy as spend, canBuy } from "../client/economy.ts";
5352 import type { Level } from "../client/level.ts";
5453 import { COMPETITIVE, SHORT, type Rules, type Team } from "../client/match.ts";
55 import { seedOf, type PlayedMap } from "../mapname.ts";
54 import type { PlayedMap } from "../mapname.ts";
5655 import { playKey } from "../matchid.ts";
5756 import type { Result } from "../result.ts";
5857 import {
@@ -133,6 +132,12 @@ export type Room = {
133132 pressed: Map<number, { pressed: Pressed; tick: number }>;
134133 /** What each bot is called, by seat, so a leave gives the name back. */
135134 botNames: string[];
135 /**
136 * The stamp of the input each seat's body was last stepped with, by seat:
137 * what a member's snapshot acknowledges, so their page knows which of its
138 * own inputs the position in it is already the result of.
139 */
140 applied: Map<number, number>;
136141 /** The buy each seat has already been charged for, so one ask is one buy. */
137142 bought: Map<number, string>;
138143 /** The world clock of the newest feed entry and line already sent out. */
@@ -149,8 +154,7 @@ export type Room = {
149154 };
150155
151156 /** The level a room stands on: de_forge, or the one that map name carves. */
152 export const levelOf = (map: PlayedMap): Level =>
153 map === "de_forge" ? DE_FORGE : generateLevel(seedOf(map) ?? 0);
157 export const levelOf = (map: PlayedMap): Level => levelNamed(map);
154158
155159 /**
156160 * A room on a map, with ten bots in it. The world is the shooter's own and so is
@@ -180,6 +184,7 @@ export function createRoom(
180184 tick: 0,
181185 members: new Map(),
182186 pressed: new Map(),
187 applied: new Map(),
183188 botNames: world.players.map((p) => p.name),
184189 bought: new Map(),
185190 loggedFeed: 0,
@@ -294,6 +299,7 @@ export function leave(room: Room, id: string, now: number): Member | null {
294299 }
295300 room.members.delete(id);
296301 room.pressed.delete(member.seat);
302 room.applied.delete(member.seat);
297303 room.bought.delete(member.seat);
298304 room.usedAt = now;
299305 return member;
@@ -340,6 +346,7 @@ function driveSeat(room: Room, seat: number, p: Player): Input | undefined {
340346 // keeps running: see `INPUT_HOLD`.
341347 if (room.tick - held.tick > INPUT_HOLD) return { ...NO_INPUT };
342348 const { pressed } = held;
349 room.applied.set(seat, pressed.tick);
343350 p.yaw = pressed.yaw;
344351 p.pitch = pressed.pitch;
345352 // One ask is one buy: the frame goes up every tick while the key is held, and
@@ -373,7 +380,7 @@ export function stepRoom(room: Room): void {
373380 * because the buffer is one room's, written through each time.
374381 */
375382 export function snapshotFor(room: Room, member: Member): Uint8Array {
376 const bytes = writeSnapshot(room.world, room.tick, member.sentAt, room.frame);
383 const bytes = writeSnapshot(room.world, room.tick, member.sentAt, room.frame, room.applied.get(member.seat) ?? 0);
377384 member.sentAt = room.world.time;
378385 return bytes;
379386 }
src/rooms/wire.test.ts +13
@@ -180,6 +180,19 @@ test("a snapshot that is not one changes nothing", () => {
180180 expect([...(mine.players[1] as Player).pos]).toEqual(was);
181181 });
182182
183 test("a snapshot ends with the input it answers, and one without it answers nothing", () => {
184 const r = room();
185 const mine = watcher(r);
186 const plain = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX)).slice();
187 const acked = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX), 4242).slice();
188 // Four bytes on the end and not one byte anywhere else: a page on the
189 // previous deploy reads the same frame it always read.
190 expect(acked.byteLength).toBe(plain.byteLength + 4);
191 expect([...acked.subarray(0, plain.byteLength)]).toEqual([...plain]);
192 expect(applySnapshot(mine, acked, 0)?.ack).toBe(4242);
193 expect(applySnapshot(mine, plain, 0)?.ack).toBe(null);
194 });
195
183196 test("a control message off the wire is validated field by field", () => {
184197 const hello = readDown(JSON.stringify({
185198 k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 10,
src/rooms/wire.ts +27 −3
@@ -197,7 +197,21 @@ const EFFECT_KINDS = ["tracer", "impact", "blood", "shot", "blast", "sound"] as
197197 * accept: `src/client/session.ts` predicted a position for this tick already and
198198 * reconciles against this one.
199199 */
200 export type Snapshot = { tick: number; time: number; events: number; own: { pos: Vec3; vel: Vec3 } | null };
200 export type Snapshot = {
201 tick: number;
202 time: number;
203 events: number;
204 own: { pos: Vec3; vel: Vec3 } | null;
205 /**
206 * The stamp of the newest input of this member's the room had applied to
207 * their body when it took this snapshot, or null for a frame that does not
208 * carry one. It is the one number prediction needs that the rest of the
209 * frame cannot give it: `tick` above is the *room's* step count and an input
210 * is stamped with the *member's* own count of frames sent, and the two
211 * clocks never meet. See the trailer in `writeSnapshot`.
212 */
213 ack: number | null;
214 };
201215
202216 /**
203217 * One room's round, whole, into `into`. Everything in `world` that a screen or
@@ -210,7 +224,9 @@ export type Snapshot = { tick: number; time: number; events: number; own: { pos:
210224 * has no hit marker and no damage arrow yet, which is stated on the page and in
211225 * the README rather than faked.
212226 */
213 export function writeSnapshot(world: World, tick: number, since: number, into: Uint8Array): Uint8Array {
227 export function writeSnapshot(
228 world: World, tick: number, since: number, into: Uint8Array, ack: number | null = null,
229 ): Uint8Array {
214230 const c = new Cursor(into);
215231 c.u8(SNAPSHOT_FRAME);
216232 c.u32(tick);
@@ -256,6 +272,12 @@ export function writeSnapshot(world: World, tick: number, since: number, into: U
256272 const events = eventsSince(world, since);
257273 c.u8(events.length);
258274 for (const e of events) writeEvent(c, e);
275 // The trailer: which of the member's own inputs this is the result of (see
276 // `Snapshot.ack`). It is last, after everything else, so a page still open on
277 // the deploy before it reads the frame it always read and never looks here —
278 // and a frame without it is read as one that carries no acknowledgement,
279 // which is a page that simply does not predict.
280 if (ack !== null) c.u32(ack);
259281 return into.subarray(0, c.at);
260282 }
261283
@@ -435,7 +457,9 @@ export function applySnapshot(world: World, bytes: Uint8Array, seat: number): Sn
435457 pushEffect(world.effects, effect, effect.at);
436458 read += 1;
437459 }
438 return { tick, time, events: read, own };
460 // Only a frame read to the end of its events is aligned on the trailer.
461 const ack = read === events && c.has(4) ? c.ru32() : null;
462 return { tick, time, events: read, own, ack };
439463 }
440464
441465 let mine: { pos: Vec3; vel: Vec3 } | null = null;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs