fable/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]>
3 files
changed +93
−4
README.md +12 −2
| @@ -59,7 +59,17 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 59 | 59 | snapshot forward by up to a quarter second of held input. It is a drawing |
| 60 | 60 | optimisation only: nothing is sent, every snapshot leans the drawn body |
| 61 | 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. | |
| 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. | |
| 63 | 73 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 64 | 74 | no looking up or down, and everything that is not a wall — bodies, the |
| 65 | 75 | 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 | ||
| 113 | 123 | live match and its sockets · `src/server.ts` the door · `static/` the browser client — |
| 114 | 124 | `val.js` wires the socket, the keys and the HUD, `raycast.js` is the ray |
| 115 | 125 | 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 | |
| 117 | 127 | them into one content-named file at start-up. The rules are pure functions and the tests play whole matches |
| 118 | 128 | headlessly. |
| 119 | 129 |
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"; | ||
| 29 | 29 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 30 | 30 | |
| 31 | 31 | 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); | |
| 33 | 46 | |
| 34 | 47 | export type Seat = { playerId: string; token: string; address: string; send: (data: string) => void }; |
| 35 | 48 | |
| @@ -378,7 +391,7 @@ function step(): void { | ||
| 378 | 391 | } |
| 379 | 392 | if (state.result === null) lastResult = null; |
| 380 | 393 | ticks += 1; |
| 381 | if (ticks % BROADCAST_EVERY !== 0) return; | |
| 394 | if (ticks % broadcastEvery(seats.size) !== 0) return; | |
| 382 | 395 | for (const seat of seats.values()) { |
| 383 | 396 | try { |
| 384 | 397 | seat.send(snapshot(seat.playerId)); |