rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
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]>
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>>(); | ||
| 112 | 112 | for (const id of [ |
| 113 | 113 | "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle", |
| 114 | 114 | "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage", |
| 115 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", | |
| 115 | "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf", | |
| 116 | 116 | ]) |
| 117 | 117 | nodes.set(id, element(id)); |
| 118 | 118 | |
| @@ -263,6 +263,22 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 263 | 263 | (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1; |
| 264 | 264 | for (const fn of listeners.get("resize") ?? []) fn({}); |
| 265 | 265 | |
| 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 | ||
| 266 | 282 | // Holding W reaches the server as a direction, and it is the way we face. |
| 267 | 283 | const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!; |
| 268 | 284 | press("KeyW"); |
src/pages/game.ts +10 −2
| @@ -76,6 +76,10 @@ export function gamePage(opts: { | ||
| 76 | 76 | #aimhint { position: absolute; inset: auto 0 45% 0; display: none; place-items: center; |
| 77 | 77 | font-size: .85rem; letter-spacing: .08em; text-transform: uppercase; |
| 78 | 78 | 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; } | |
| 79 | 83 | #help { position: fixed; inset: 0; z-index: 10; display: none; place-items: center; |
| 80 | 84 | background: #080a0ef2; padding: .8rem; overflow: auto; } |
| 81 | 85 | #help > div { max-width: 40rem; } |
| @@ -156,6 +160,7 @@ export function gamePage(opts: { | ||
| 156 | 160 | <div id="aimhint">click to aim · esc releases</div> |
| 157 | 161 | <div id="dead">waiting for the next round</div> |
| 158 | 162 | <div id="feed"></div> |
| 163 | <div id="perf" aria-live="off"></div> | |
| 159 | 164 | <div id="prompt">Click the map to aim, then press <b>H</b> for how to play.</div> |
| 160 | 165 | <div id="help" role="dialog" aria-label="How to play val"><div> |
| 161 | 166 | <h2>How to play</h2> |
| @@ -169,7 +174,8 @@ export function gamePage(opts: { | ||
| 169 | 174 | click the view to capture the mouse and look, <kbd>Esc</kbd> releases · |
| 170 | 175 | click to fire · <kbd>R</kbd> reload · hold <kbd>F</kbd> to plant, defuse or pick up · |
| 171 | 176 | <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> | |
| 173 | 179 | <p><strong>Abilities.</strong> <kbd>C</kbd> and <kbd>Q</kbd> are bought in the buy phase and |
| 174 | 180 | last the round, <kbd>E</kbd> is free and comes back on a cooldown, and <kbd>X</kbd> is |
| 175 | 181 | the ultimate, paid for in ult points you earn by playing.</p> |
| @@ -238,7 +244,9 @@ export function gamePage(opts: { | ||
| 238 | 244 | <p><strong>Controls.</strong> WASD move · click the view to capture the mouse, move to look, |
| 239 | 245 | Esc releases · click fires · R reloads · <strong>F</strong> plants and defuses (hold it) · |
| 240 | 246 | 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. | |
| 242 | 250 | On a phone it stays top-down: left thumb walks, right thumb aims and fires.</p> |
| 243 | 251 | <p>Five attackers carry the spike, five defenders stop them. 100s round, 4s plant, 45s fuse, |
| 244 | 252 | 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 @@ | ||
| 12 | 12 | "use strict"; |
| 13 | 13 | |
| 14 | 14 | 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"; | |
| 16 | 17 | import { BODY_R, moveSpeed } from "../src/glide.ts"; |
| 17 | 18 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; |
| 18 | 19 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; |
| 19 | 20 | import { choosePrompt, REACH } from "./prompt.js"; |
| 20 | import { renderSize, startLevel } from "./resolution.js"; | |
| 21 | import { renderSize, startLevel, WIDTHS } from "./resolution.js"; | |
| 21 | 22 | import { roster, rosterKey } from "./roster.js"; |
| 22 | 23 | import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js"; |
| 23 | 24 | import { moveVector } from "./steer.js"; |
| @@ -64,10 +65,13 @@ let aimTouch = null; | ||
| 64 | 65 | let helpShown = false; |
| 65 | 66 | let last = performance.now(); |
| 66 | 67 | 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); | |
| 70 | 72 | let render = { width: 960, height: 600 }; |
| 73 | let perfOn = false; | |
| 74 | let perfAt = 0; | |
| 71 | 75 | |
| 72 | 76 | const walk = { x: 0, y: 0 }; |
| 73 | 77 | // What this person is doing right now, read once a frame for the drawing and |
| @@ -149,6 +153,11 @@ addEventListener("keydown", (event) => { | ||
| 149 | 153 | event.preventDefault(); |
| 150 | 154 | return; |
| 151 | 155 | } |
| 156 | if (event.code === "F3" || event.code === "Backquote") { | |
| 157 | togglePerf(); | |
| 158 | event.preventDefault(); | |
| 159 | return; | |
| 160 | } | |
| 152 | 161 | if (KEY_SLOT[event.code]) { |
| 153 | 162 | send({ t: "i", c: KEY_SLOT[event.code] }); |
| 154 | 163 | event.preventDefault(); |
| @@ -299,14 +308,15 @@ function resize() { | ||
| 299 | 308 | const dpr = Math.min(devicePixelRatio || 1, 2); |
| 300 | 309 | view.width = Math.round(box.width * dpr); |
| 301 | 310 | 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; | |
| 303 | 313 | sizeRender(); |
| 304 | 314 | } |
| 305 | 315 | addEventListener("resize", resize); |
| 306 | 316 | |
| 307 | 317 | /** The offscreen the world is drawn into, at the level in force right now. */ |
| 308 | 318 | function sizeRender() { |
| 309 | render = renderSize(view.width, view.height, level); | |
| 319 | render = renderSize(view.width, view.height, budget.level); | |
| 310 | 320 | if (sceneCanvas.width !== render.width) sceneCanvas.width = render.width; |
| 311 | 321 | if (sceneCanvas.height !== render.height) sceneCanvas.height = render.height; |
| 312 | 322 | } |
| @@ -363,6 +373,7 @@ function predict(dt, now) { | ||
| 363 | 373 | |
| 364 | 374 | function frame(now) { |
| 365 | 375 | requestAnimationFrame(frame); |
| 376 | const began = performance.now(); | |
| 366 | 377 | const dt = Math.min(0.1, (now - last) / 1000); |
| 367 | 378 | last = now; |
| 368 | 379 | drawAt = renderTime(timeline, now / 1000); |
| @@ -405,6 +416,39 @@ function frame(now) { | ||
| 405 | 416 | $("flash").style.opacity = Math.min(0.95, snap.me.blind * 0.8); |
| 406 | 417 | $("dead").style.display = snap.me.alive ? "none" : "grid"; |
| 407 | 418 | $("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}`; | |
| 408 | 452 | } |
| 409 | 453 | |
| 410 | 454 | /** The same top-down drawing, small, in the corner, with you as an arrow. */ |