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

Adapt the render width to the frame, and print the numbers

Two things, and neither of them changes a byte of what is sent, predicted or
simulated.

The governor: budget.js measures what the last second of frames cost to
draw and moves the resolution ladder one rung at a time — coarser over 20ms
mean, finer again only after two windows under 13ms, and never finer than
the rung the window started on. Different thresholds, patience before
refining and one step a second is what keeps it from flapping, which would
be more visible than a resolution that is simply low. It measures the work
the frame does, not the gap between frames: a machine with headroom is
capped at its refresh rate and would never earn a rung back.

The readout: F3 or ` shows frame rate, mean frame ms, the snapshot rate
actually arriving, the interpolation delay in force and the internal render
width against the canvas size. "It feels choppy" and "it delivers 30Hz
cleanly" were both true and nobody could tell which part was at fault.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 42b76dc Browse files
5 files changed +221 −10
src/budget.test.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * The frame governor, fed frames by hand. A second of slow frames should
3 * cost one rung and not four; a second of fast ones should not immediately
4 * give it back, because a resolution that flaps is worse than a low one.
5 */
6
7 import { expect, test } from "bun:test";
8 import { FAST, makeBudget, measure, PATIENCE, SLOW, WINDOW } from "../static/budget.js";
9
10 /** `seconds` of frames, each `ms` long, and how many times the level moved. */
11 function run(budget: ReturnType<typeof makeBudget>, ms: number, seconds: number, from = 0) {
12 let at = from;
13 let moves = 0;
14 const end = from + seconds;
15 while (at < end) {
16 at += ms / 1000;
17 if (measure(budget, ms, at)) moves++;
18 }
19 return { moves, at };
20 }
21
22 test("a second over budget costs exactly one rung, not four", () => {
23 const budget = makeBudget(0);
24 const { moves } = run(budget, SLOW + 8, WINDOW * 1.5);
25 expect(moves).toBe(1);
26 expect(budget.level).toBe(1);
27 });
28
29 test("frames inside the budget leave the level alone", () => {
30 const budget = makeBudget(2);
31 run(budget, (SLOW + FAST) / 2, 10);
32 expect(budget.level).toBe(2);
33 });
34
35 test("headroom gives a rung back, but not before two windows of it", () => {
36 const budget = makeBudget(0);
37 let at = run(budget, SLOW + 8, WINDOW * 1.2).at;
38 expect(budget.level).toBe(1);
39 at = run(budget, 8, WINDOW * 1.2, at).at;
40 expect(budget.level).toBe(1); // one good window is not enough
41 run(budget, 8, WINDOW * 1.2, at);
42 expect(budget.level).toBe(0);
43 });
44
45 test("it never refines past the rung the window started on", () => {
46 const budget = makeBudget(2);
47 run(budget, 4, 20);
48 expect(budget.level).toBe(2);
49 expect(budget.good).toBeLessThanOrEqual(PATIENCE);
50 });
51
52 test("it stops at the coarsest rung rather than walking off the ladder", () => {
53 const budget = makeBudget(0, 3);
54 run(budget, 60, 40);
55 expect(budget.level).toBe(3);
56 });
57
58 test("one slow window, then one fast, does not oscillate", () => {
59 const budget = makeBudget(0);
60 let at = 0;
61 const levels: number[] = [];
62 for (let i = 0; i < 8; i++) {
63 at = run(budget, i % 2 === 0 ? SLOW + 8 : 6, WINDOW * 1.05, at).at;
64 levels.push(budget.level);
65 }
66 // Alternating windows never hand a rung back — it takes two good ones —
67 // so the level climbs slowly and stays put rather than flickering.
68 expect(levels.filter((l, i) => i > 0 && l < levels[i - 1]!).length).toBe(0);
69 });
70
71 test("the window reports the frame rate and the mean it decided on", () => {
72 const budget = makeBudget(0);
73 run(budget, 20, WINDOW * 1.1);
74 expect(budget.mean).toBeCloseTo(20, 1);
75 expect(budget.fps).toBeGreaterThan(45);
76 expect(budget.fps).toBeLessThan(55);
77 });
src/client.test.ts +17 −1
@@ -112,7 +112,7 @@ const nodes = new Map<string, Record<string, unknown>>();
112112 for (const id of [
113113 "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
114114 "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
115 "log", "atkboard", "defboard", "helpagents", "roster", "feed",
115 "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf",
116116 ])
117117 nodes.set(id, element(id));
118118
@@ -263,6 +263,22 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
263263 (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1;
264264 for (const fn of listeners.get("resize") ?? []) fn({});
265265
266 // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be
267 // told apart: the frame rate, the mean frame, the snapshot rate, the
268 // interpolation delay, and the width the world is really drawn at.
269 const perf = nodes.get("perf")!;
270 expect((perf.style as Record<string, string>).display).not.toBe("block");
271 press("Backquote");
272 expect((perf.style as Record<string, string>).display).toBe("block");
273 const readout = String(perf.textContent);
274 expect(readout).toContain("fps");
275 expect(readout).toContain("ms/frame");
276 expect(readout).toContain("Hz in");
277 expect(readout).toContain("ms behind");
278 expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
279 press("Backquote");
280 expect((perf.style as Record<string, string>).display).toBe("none");
281
266282 // Holding W reaches the server as a direction, and it is the way we face.
267283 const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
268284 press("KeyW");
src/pages/game.ts +10 −2
@@ -76,6 +76,10 @@ export function gamePage(opts: {
7676 #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center;
7777 font-size: .85rem; letter-spacing: .08em; text-transform: uppercase;
7878 text-shadow: 0 1px 4px #000; pointer-events: none; }
79 #perf { position: absolute; left: .5rem; bottom: 2.6rem; display: none; pointer-events: none;
80 padding: .2rem .45rem; border-radius: .3rem; background: #0b0d11cc; border: 1px solid #2f3541;
81 font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap;
82 max-width: calc(100% - 1rem); overflow: hidden; text-overflow: ellipsis; }
7983 #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center;
8084 background: #080a0ef2; padding: .8rem; overflow: auto; }
8185 #help > div { max-width: 40rem; }
@@ -156,6 +160,7 @@ export function gamePage(opts: {
156160 <div id="aimhint">click to aim · esc releases</div>
157161 <div id="dead">waiting for the next round</div>
158162 <div id="feed"></div>
163 <div id="perf" aria-live="off"></div>
159164 <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div>
160165 <div id="help" role="dialog" aria-label="How to play val"><div>
161166 <h2>How to play</h2>
@@ -169,7 +174,8 @@ export function gamePage(opts: {
169174 click the view to capture the mouse and look, <kbd>Esc</kbd> releases ·
170175 click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up ·
171176 <kbd>Tab</kbd> holds the big map open · <kbd>V</kbd> swaps to the top-down view ·
172 <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back.</p>
177 <kbd>B</kbd> opens the buy · <kbd>H</kbd> brings this card back ·
178 <kbd>F3</kbd> or <kbd>\`</kbd> shows the frame readout.</p>
173179 <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and
174180 last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is
175181 the ultimate, paid for in ult points you earn by playing.</p>
@@ -238,7 +244,9 @@ export function gamePage(opts: {
238244 <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look,
239245 Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) ·
240246 C / Q / E / X cast · Tab holds the map open · V swaps to the top-down view ·
241 B opens the buy · H shows how to play.
247 B opens the buy · H shows how to play · F3 or \` prints the frame numbers:
248 frame rate, mean frame, snapshots a second, the interpolation delay and how
249 wide the world is being drawn before it is scaled onto the canvas.
242250 On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p>
243251 <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse,
244252 7s defuse with a checkpoint at half. First to 13, sides swap at 12. Bots fill every seat
static/budget.js added +66
@@ -0,0 +1,66 @@
1 /**
2 * The frame budget: what the last second of frames cost, and one knob.
3 *
4 * The renderer's price is the number of columns it draws, and the right
5 * number is not knowable from here — it depends on the machine, the window,
6 * the battery and whatever else the browser is doing. So it is measured: a
7 * second of frames at a time, and if the mean frame is over the budget the
8 * world is drawn a rung coarser (see resolution.js), and a rung finer again
9 * when there is room.
10 *
11 * It must not flap, because a resolution changing twice a second is more
12 * visible than a resolution that is simply low. Three things stop it:
13 * coarsening and refining have different thresholds, refining needs two
14 * windows of headroom in a row, and it never refines past the rung the
15 * window started on.
16 *
17 * Measurement only. Nothing here is sent anywhere or told to the simulation.
18 */
19
20 /** Seconds of frames behind one decision. */
21 export const WINDOW = 1;
22 /** Mean milliseconds a frame: over this is over budget, under this has room. */
23 export const SLOW = 20;
24 export const FAST = 13;
25 /** Windows of headroom before a rung is given back. */
26 export const PATIENCE = 2;
27
28 export function makeBudget(base = 0, max = 6) {
29 return { base, max, level: base, frames: 0, total: 0, since: 0, good: 0, fps: 0, mean: 0 };
30 }
31
32 /**
33 * One frame, `ms` long, drawn at `now` seconds. Returns true when the level
34 * moved — the caller resizes what it draws into — and updates `fps` and
35 * `mean` at the end of every window, for the readout to print.
36 */
37 export function measure(budget, ms, now) {
38 budget.frames++;
39 budget.total += ms;
40 if (budget.since === 0) {
41 budget.since = now;
42 return false;
43 }
44 const span = now - budget.since;
45 if (span < WINDOW) return false;
46 budget.mean = budget.total / budget.frames;
47 budget.fps = budget.frames / span;
48 budget.frames = 0;
49 budget.total = 0;
50 budget.since = now;
51 if (budget.mean > SLOW) {
52 budget.good = 0;
53 if (budget.level >= budget.max) return false;
54 budget.level++;
55 return true;
56 }
57 if (budget.mean < FAST && budget.level > budget.base) {
58 budget.good++;
59 if (budget.good < PATIENCE) return false;
60 budget.good = 0;
61 budget.level--;
62 return true;
63 }
64 budget.good = 0;
65 return false;
66 }
static/val.js +51 −7
@@ -12,12 +12,13 @@
1212 "use strict";
1313
1414 import { makeBeeper } from "./beep.js";
15 import { arrive, bodyAt, feed, makeTimeline, renderTime } from "./interp.js";
15 import { makeBudget, measure } from "./budget.js";
16 import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js";
1617 import { BODY_R, moveSpeed } from "../src/glide.ts";
1718 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
1819 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
1920 import { choosePrompt, REACH } from "./prompt.js";
20 import { renderSize, startLevel } from "./resolution.js";
21 import { renderSize, startLevel, WIDTHS } from "./resolution.js";
2122 import { roster, rosterKey } from "./roster.js";
2223 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2324 import { moveVector } from "./steer.js";
@@ -64,10 +65,13 @@ let aimTouch = null;
6465 let helpShown = false;
6566 let last = performance.now();
6667 let snapAt = last; // when the last snapshot landed: how stale the answer is
67 // How many columns the world is drawn at, and which rung of the ladder that
68 // is. See resolution.js: the visible canvas is device pixels, this is not.
69 let level = 0;
68 // How many columns the world is drawn at, and what the frame is costing.
69 // See resolution.js for the ladder and budget.js for the rung it settles on:
70 // the visible canvas is device pixels, this is not.
71 const budget = makeBudget(startLevel(900), WIDTHS.length - 1);
7072 let render = { width: 960, height: 600 };
73 let perfOn = false;
74 let perfAt = 0;
7175
7276 const walk = { x: 0, y: 0 };
7377 // What this person is doing right now, read once a frame for the drawing and
@@ -149,6 +153,11 @@ addEventListener("keydown", (event) => {
149153 event.preventDefault();
150154 return;
151155 }
156 if (event.code === "F3" || event.code === "Backquote") {
157 togglePerf();
158 event.preventDefault();
159 return;
160 }
152161 if (KEY_SLOT[event.code]) {
153162 send({ t: "i", c: KEY_SLOT[event.code] });
154163 event.preventDefault();
@@ -299,14 +308,15 @@ function resize() {
299308 const dpr = Math.min(devicePixelRatio || 1, 2);
300309 view.width = Math.round(box.width * dpr);
301310 view.height = Math.round(box.height * dpr);
302 level = startLevel(box.width);
311 budget.base = startLevel(box.width);
312 if (budget.level < budget.base) budget.level = budget.base;
303313 sizeRender();
304314 }
305315 addEventListener("resize", resize);
306316
307317 /** The offscreen the world is drawn into, at the level in force right now. */
308318 function sizeRender() {
309 render = renderSize(view.width, view.height, level);
319 render = renderSize(view.width, view.height, budget.level);
310320 if (sceneCanvas.width !== render.width) sceneCanvas.width = render.width;
311321 if (sceneCanvas.height !== render.height) sceneCanvas.height = render.height;
312322 }
@@ -363,6 +373,7 @@ function predict(dt, now) {
363373
364374 function frame(now) {
365375 requestAnimationFrame(frame);
376 const began = performance.now();
366377 const dt = Math.min(0.1, (now - last) / 1000);
367378 last = now;
368379 drawAt = renderTime(timeline, now / 1000);
@@ -405,6 +416,39 @@ function frame(now) {
405416 $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8);
406417 $("dead").style.display = snap.me.alive ? "none" : "grid";
407418 $("aimhint").style.display = mode === "fp" && !locked && !COARSE ? "grid" : "none";
419
420 // What this frame cost to draw, not how long the browser waited for the
421 // next one: a machine with headroom is capped at its refresh rate, and the
422 // renderer would never earn a rung back if that were the measure.
423 const ended = performance.now();
424 if (measure(budget, ended - began, ended / 1000)) sizeRender();
425 paintPerf(ended);
426 }
427
428 /** F3 or ` — the numbers, so "it feels choppy" has something to argue with. */
429 function togglePerf() {
430 perfOn = !perfOn;
431 const box = $("perf");
432 if (!box) return;
433 box.style.display = perfOn ? "block" : "none";
434 perfAt = 0;
435 if (perfOn) paintPerf(performance.now());
436 }
437
438 /**
439 * The readout, rewritten twice a second: the frame rate and the mean frame
440 * the governor decided on, the rate snapshots are actually arriving at, the
441 * interpolation delay in force, and how wide the world is being drawn.
442 */
443 function paintPerf(nowMs) {
444 const box = $("perf");
445 if (!perfOn || !box || nowMs - perfAt < 500) return;
446 perfAt = nowMs;
447 const hz = timeline.interval > 0 ? 1 / timeline.interval : 0;
448 box.textContent =
449 `${budget.fps.toFixed(1)} fps · ${budget.mean.toFixed(1)} ms/frame · ` +
450 `${hz.toFixed(1)} Hz in · ${(delay(timeline) * 1000).toFixed(0)} ms behind · ` +
451 `${render.width}×${render.height} of ${view.width}×${view.height}`;
408452 }
409453
410454 /** The same top-down drawing, small, in the corner, with you as an arrow. */
↑↓ navigate ↵ open esc close

Keyboard shortcuts