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

forked from benjivers/val

workbenchthis machine

Thirty snapshots a second while the room is small

The simulation already runs at 30Hz; broadcasting every other tick gave the
browser fifteen positions a second to draw sixty frames from, and with
interpolation that is also twice the delay the drawing has to sit behind the
present. A room of two or fewer seats — one person against the bots, or two
people against each other, which is the case that matters — now hears every
tick; above that the older cadence comes back and the cost stops multiplying
by seats.

Measured over a live round a snapshot is 3.2-4.5 KiB, mean 3.4: about 100
KiB/s a seat at 30Hz, for at most two seats, and only while somebody is
connected now that the arena idles. The test asserts the size so the number
in the pull is checked rather than assumed.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 4a6997b Browse files
3 files changed +93 −4
README.md +12 −2
@@ -59,7 +59,17 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
5959 snapshot forward by up to a quarter second of held input. It is a drawing
6060 optimisation only: nothing is sent, every snapshot leans the drawn body
6161 back onto the server's answer, and a real disagreement is taken outright.
62 Everybody else is still smoothed toward the last position the server sent.
62 Where you are *looking* is the browser's too: the mouse moves it at frame
63 rate, and the server's angle is taken back only when it is telling you
64 something you did not say (`static/yaw.js`).
65 - Everybody else is drawn by entity interpolation (`static/interp.js`): each
66 body keeps the last few snapshots with the times they landed, and the frame
67 is drawn about one snapshot interval in the past, between the two that
68 bracket that moment. So they move at the speed the server moved them and
69 arrive where it put them, one interval late. A missed snapshot is carried
70 one interval further and then held. Snapshots go out every tick (30Hz, some
71 3.4 KiB a seat) while two or fewer seats are filled, and every other tick
72 above that.
6373 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
6474 no looking up or down, and everything that is not a wall — bodies, the
6575 spike, ability zones — is a flat billboard scaled by distance and clipped
@@ -113,7 +123,7 @@ no clock) · `src/bots.ts` the seats nobody is in · `src/arena.ts` the one
113123 live match and its sockets · `src/server.ts` the door · `static/` the browser client —
114124 `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray
115125 maths, `scene.js` the first-person view, `topdown.js` the minimap,
116 `gun.js`, `beep.js`, `steer.js`, `roster.js` and `prompt.js` the rest; the server bundles
126 `gun.js`, `beep.js`, `steer.js`, `roster.js`, `yaw.js`, `interp.js` and `prompt.js` the rest; the server bundles
117127 them into one content-named file at start-up. The rules are pure functions and the tests play whole matches
118128 headlessly.
119129
src/arena.test.ts added +66
@@ -0,0 +1,66 @@
1 /**
2 * How often the arena speaks.
3 *
4 * The simulation is 30Hz either way; what is decided here is how much of it
5 * the browser is told about, which is the difference between a body drawn
6 * from fifteen samples a second and one drawn from thirty.
7 */
8
9 import { expect, test } from "bun:test";
10 import { mkdtempSync } from "node:fs";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13
14 process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
15 const { advance, attach, broadcastEvery, match, release, seatCount, snapshot } = await import("./arena.ts");
16
17 test("a small room hears every tick, a full one every other", () => {
18 expect(broadcastEvery(0)).toBe(1);
19 expect(broadcastEvery(1)).toBe(1);
20 expect(broadcastEvery(2)).toBe(1);
21 expect(broadcastEvery(3)).toBe(2);
22 expect(broadcastEvery(10)).toBe(2);
23 });
24
25 /** A seat that only counts what it was sent. */
26 function listen(session: string) {
27 const got: string[] = [];
28 attach(session, match().players[0]!.id, `test-${session}`, session, (data) => got.push(data));
29 return got;
30 }
31
32 // The arena is one match shared by the whole test run, and another file may
33 // be holding a seat of its own while this one runs; what is checked is that
34 // the loop broadcasts at exactly the cadence the room it is in asks for.
35 test("the loop sends at the cadence the seat count asks for", () => {
36 const one = listen("rate-a");
37 const two = listen("rate-b");
38 const every = broadcastEvery(seatCount());
39 advance(1);
40 // Thirty ticks to the second, one snapshot every `every` of them.
41 expect(one.length).toBe(30 / every);
42 expect(two.length).toBe(one.length);
43 release("rate-a", "rate-a");
44 release("rate-b", "rate-b");
45 });
46
47 test("a full room halves the cadence rather than multiplying the traffic", () => {
48 const seats = ["rate-c", "rate-d", "rate-e"].map(listen);
49 expect(seatCount()).toBeGreaterThan(2);
50 advance(1);
51 for (const got of seats) expect(got.length).toBe(15);
52 for (const session of ["rate-c", "rate-d", "rate-e"]) release(session, session);
53 });
54
55 test("a snapshot stays small enough to send thirty times a second", () => {
56 // The trade is only worth making if the payload is small. Measured over a
57 // live round it is 3.2-4.5 KiB a seat, mean 3.4 — a hundred KiB a second
58 // at 30Hz, and at most two seats are sent that much. This is the number
59 // the pull quotes, so it is checked here rather than assumed; the ceiling
60 // is loose enough not to fail on a busy tick and tight enough that a
61 // snapshot which doubled in size would say so.
62 for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1);
63 const bytes = Buffer.byteLength(snapshot(match().players[0]!.id));
64 expect(bytes).toBeGreaterThan(200);
65 expect(bytes).toBeLessThan(8000);
66 });
src/arena.ts +15 −2
@@ -29,7 +29,20 @@ import type { MatchState, Player } from "./state.ts";
2929 import { isWeaponId, WEAPONS } from "./weapons.ts";
3030
3131 export const TICK = 1 / 30;
32 const BROADCAST_EVERY = 2;
32
33 /**
34 * How many ticks apart the snapshots go out.
35 *
36 * The simulation runs at 30Hz and used to broadcast every other tick, which
37 * gave the browser fifteen positions a second to draw sixty frames from —
38 * not many, and with interpolation it also doubles the delay the drawing has
39 * to sit behind the present. A small room gets every tick: it is the case
40 * that matters (one or two people playing each other), the payload is a
41 * couple of kilobytes of JSON a seat, and the arena only runs at all while
42 * somebody is connected. Above that the cost multiplies by seats and the
43 * older cadence comes back.
44 */
45 export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2);
3346
3447 export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void };
3548
@@ -378,7 +391,7 @@ function step(): void {
378391 }
379392 if (state.result === null) lastResult = null;
380393 ticks += 1;
381 if (ticks % BROADCAST_EVERY !== 0) return;
394 if (ticks % broadcastEvery(seats.size) !== 0) return;
382395 for (const seat of seats.values()) {
383396 try {
384397 seat.send(snapshot(seat.playerId));
↑↓ navigate ↵ open esc close

Keyboard shortcuts