quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Merge pull request #17 from fable/16-make-val-feel-right-end-to-end-the-whole
Make val feel right end to end — the whole feel pass, measured
10 files
changed +717
−76
README.md +27 −9
| @@ -59,17 +59,32 @@ 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 | The lag your own keys take to reach the server (a send every 50ms, a hop, | |
| 63 | a tick — `LEAD_SLACK` in `static/predict.js`) is not a disagreement: the | |
| 64 | drawn body may lead the answer by that much along the way it is going and | |
| 65 | is left alone there, so a start is not yanked back and a stop is not | |
| 66 | dragged on. Anywhere the server would refuse — a wall, the spawn pen in | |
| 67 | the buy phase, a barrier an ability put up — the drawing holds its last | |
| 68 | good place rather than snapping to the server's. The gun kicks the moment | |
| 69 | the trigger is pulled, and the server's word about the same shot is not a | |
| 70 | second kick; whether it landed is still only the server's to say. | |
| 62 | 71 | Where you are *looking* is the browser's too: the mouse moves it at frame |
| 63 | 72 | rate, and the server's angle is taken back only when it is telling you |
| 64 | 73 | something you did not say (`static/yaw.js`). |
| 65 | 74 | - 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. | |
| 75 | body keeps the last few snapshots, and the frame is drawn about one | |
| 76 | snapshot interval in the past, between the two that bracket that moment. | |
| 77 | So they move at the speed the server moved them and arrive where it put | |
| 78 | them, one interval late. A missed snapshot is carried one interval further | |
| 79 | and then held. Snapshots are filed under an even clock — one interval | |
| 80 | after the last, nudged a tenth of the way toward when each really landed — | |
| 81 | rather than the moment they happened to arrive, because a rate is not a | |
| 82 | rhythm: thirty a second delivered a few milliseconds early and late is a | |
| 83 | body drawn slow, then fast. The moment being drawn advances at real time | |
| 84 | and creeps toward its delay at no more than two percent of it, so the | |
| 85 | delay adapting never shows as a short frame. Snapshots go out every tick | |
| 86 | (30Hz, some 3.4 KiB a seat) while two or fewer seats are filled, and every | |
| 87 | other tick above that. | |
| 73 | 88 | - The view is a raycaster: walls are one tile tall and flat-shaded, there is |
| 74 | 89 | no looking up or down, and everything that is not a wall — bodies, the |
| 75 | 90 | spike, ability zones — is a flat billboard scaled by distance and clipped |
| @@ -119,8 +134,11 @@ defuses (hold it), **C / Q / E / X** cast, **B** opens the buy, **Tab** | ||
| 119 | 134 | holds the big map open, **V** swaps to the top-down view, **H** or **?** |
| 120 | 135 | brings back the how-to-play card, and **F3** or **`** prints the frame |
| 121 | 136 | readout: frame rate, mean frame, snapshots a second, the interpolation delay |
| 122 | in force and the width the world is being drawn at. On a phone it stays top-down: the left | |
| 123 | thumb walks and the right thumb aims and fires. | |
| 137 | in force, the width the world is being drawn at, and how many times the | |
| 138 | drawing has taken the server's word outright (your body anchored, somebody | |
| 139 | else started again, the view re-pointed) — every one of those is a jump, and | |
| 140 | the number a minute is what "it stutters" should be argued with. On a phone | |
| 141 | it stays top-down: the left thumb walks and the right thumb aims and fires. | |
| 124 | 142 | |
| 125 | 143 | sh |
| 126 | 144 | PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here |
src/client.test.ts +45 −13
| @@ -187,6 +187,14 @@ const until = async (what: () => boolean, ms = 4000) => { | ||
| 187 | 187 | // ------------------------------------------------------------------ the run |
| 188 | 188 | |
| 189 | 189 | test("the bundled client connects, paints the HUD and draws a frame", async () => { |
| 190 | // Every test in this run reaches the server from the one loopback address, | |
| 191 | // and a playing seat is rationed per address on purpose. A socket another | |
| 192 | // file closed a moment ago may not have given its seat back yet, and this | |
| 193 | // client would then be handed a spectator view and wait four seconds for a | |
| 194 | // health readout that never comes — the coin-flip failure the suite had. | |
| 195 | // Start from a clean slate, the way the server's own seat tests do. | |
| 196 | const { releaseAddress } = await import("./arena.ts"); | |
| 197 | for (const address of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) releaseAddress(address); | |
| 190 | 198 | const first = await fetch(base + "/"); |
| 191 | 199 | sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 192 | 200 | const page = await first.text(); |
| @@ -236,6 +244,10 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 236 | 244 | // blits it, with the minimap's tiles blitted too. It is a picture, but it |
| 237 | 245 | // is not one fill a screen column any more, nor one a map tile: the map has |
| 238 | 246 | // 570 tiles and the canvas 900 columns, and the frame used to pay for both. |
| 247 | // The minimap's tiles are the blit; the walls are a run a fill, and how | |
| 248 | // many runs a view has depends on where the seat happens to be standing — | |
| 249 | // a long corridor is many depths — so the bound is the columns, which no | |
| 250 | // view can reach, rather than the tile count, which a corridor can. | |
| 239 | 251 | const tally = () => ({ |
| 240 | 252 | fills: drawn.filter((op) => op.startsWith("fillRect")).length, |
| 241 | 253 | blits: drawn.filter((op) => op.startsWith("drawImage")).length, |
| @@ -246,7 +258,7 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 246 | 258 | const fp = tally(); |
| 247 | 259 | expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles |
| 248 | 260 | expect(fp.fills).toBeGreaterThan(40); |
| 249 | expect(fp.fills).toBeLessThan(570); | |
| 261 | expect(fp.fills).toBeLessThan(900); | |
| 250 | 262 | |
| 251 | 263 | // Tab swaps that frame for the whole map. Its tiles are drawn once into a |
| 252 | 264 | // canvas of their own and blitted from then on: the first such frame pays |
| @@ -324,10 +336,26 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 324 | 336 | // arrive at all — the drawn body still carries the keys you are holding |
| 325 | 337 | // forward, as far as the cap and no further, and stops there. |
| 326 | 338 | press("KeyV"); // the top-down view, where your own body is an arrow |
| 327 | for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); | |
| 328 | await Bun.sleep(60); | |
| 329 | runFrame(); | |
| 330 | arena.stopArena(); | |
| 339 | // The measurement needs a live round and a seat that can walk. The arena | |
| 340 | // has been run through whole rounds above with this seat standing still, | |
| 341 | // so by now it may be dead, mid-plant, slowed, or stood inside a barrier a | |
| 342 | // bot put up — and a guess about a body that cannot move is rightly nothing. | |
| 343 | // Put it on its feet, clear what abilities left, and let a snapshot carry | |
| 344 | // that to the client before the clock is stopped; try again if a round | |
| 345 | // ends or a bot lands a kill in that moment. | |
| 346 | for (let tries = 0; tries < 10; tries++) { | |
| 347 | for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1); | |
| 348 | Object.assign(seat, { alive: true, hp: 100, planting: 0, defusing: 0, haste: 0, slow: 0 }); | |
| 349 | arena.match().zones.length = 0; | |
| 350 | arena.advance(1 / 30); | |
| 351 | await Bun.sleep(80); | |
| 352 | runFrame(); | |
| 353 | arena.stopArena(); | |
| 354 | if (arena.match().phase === "live" && seat.alive && arena.match().zones.length === 0) break; | |
| 355 | arena.startArena(); | |
| 356 | } | |
| 357 | expect(arena.match().phase).toBe("live"); | |
| 358 | expect(seat.alive).toBe(true); | |
| 331 | 359 | // Walk whichever way is open from where the server last put us. |
| 332 | 360 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); |
| 333 | 361 | const { blockedCircle } = await import("./map.ts"); |
| @@ -363,9 +391,11 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 363 | 391 | // in time (predict.js LEAD_MAX), so the bound has to be read off the same |
| 364 | 392 | // speed table the client used, or it measures the match's mood instead of |
| 365 | 393 | // the rule. |
| 394 | // The lead is the cap plus the slack the guess is allowed over the answer | |
| 395 | // (predict.js LEAD_SLACK: the lag your own keys take to reach the server). | |
| 366 | 396 | const { moveSpeed } = await import("./glide.ts"); |
| 367 | const { LEAD_MAX } = await import("../static/predict.js"); | |
| 368 | expect(lead).toBeLessThan(LEAD_MAX * moveSpeed(seat) + 10); | |
| 397 | const { LEAD_MAX, LEAD_SLACK } = await import("../static/predict.js"); | |
| 398 | expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10); | |
| 369 | 399 | expect(Math.abs(sideways)).toBeLessThan(6); |
| 370 | 400 | // Nothing on the server moved: this is a drawing, and the match is still |
| 371 | 401 | // the only thing that says where anybody is. |
| @@ -410,6 +440,9 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 410 | 440 | expect(readout).toContain("Hz in"); |
| 411 | 441 | expect(readout).toContain("ms behind"); |
| 412 | 442 | expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas |
| 443 | // And the count of corrections taken outright, per minute: a stutter is a | |
| 444 | // jump, and this is how many there have been. | |
| 445 | expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/); | |
| 413 | 446 | press("Backquote"); |
| 414 | 447 | expect((perf.style as Record<string, string>).display).toBe("none"); |
| 415 | 448 | |
| @@ -479,10 +512,9 @@ test("the buy phase is predicted, and its pen is the server's own rule", async ( | ||
| 479 | 512 | expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); |
| 480 | 513 | // And the pen is the server's number, not a guess: 120 from a spawn tile. |
| 481 | 514 | expect(bundle).toContain("best > 120"); |
| 482 | // A refused step holds where it was rather than snapping to the server. | |
| 483 | const predict = bundle.slice(bundle.indexOf("function predict(")); | |
| 484 | const holds = predict.indexOf("local.x = was.x"); | |
| 485 | const anchors = predict.indexOf("anchor(local, snap.me)"); | |
| 486 | expect(holds).toBeGreaterThan(-1); | |
| 487 | expect(holds).toBeLessThan(anchors); // hold first; anchor only if still stuck | |
| 515 | // A refused step holds where it was rather than snapping to the server: | |
| 516 | // the pen is handed to the one step function as the place it is refused. | |
| 517 | expect(bundle).toContain("stepLocal(local, snap.me, held, speed"); | |
| 518 | expect(bundle).toContain("world, refused)"); | |
| 519 | expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)"); | |
| 488 | 520 | }); |
src/feel.test.ts added +363
| @@ -0,0 +1,363 @@ | ||
| 1 | /** | |
| 2 | * The whole feel, measured. | |
| 3 | * | |
| 4 | * A wire between the real match and the real client modules: snapshots leave | |
| 5 | * on the server's ticks, arrive a little late and a little unevenly, and the | |
| 6 | * browser side draws sixty frames a second from them exactly the way val.js | |
| 7 | * does. What comes out is a set of numbers a person's "it stutters" can be | |
| 8 | * held against — how even the per-frame steps are, how often a correction | |
| 9 | * jumps, how large the largest jump is — and every one of them is a rule | |
| 10 | * this file enforces from here on. | |
| 11 | */ | |
| 12 | ||
| 13 | import { expect, test } from "bun:test"; | |
| 14 | import { BODY_R, moveSpeed, RUN } from "./glide.ts"; | |
| 15 | import { solidTile, TILE } from "./map.ts"; | |
| 16 | import { newMatch, setInput, startRound, tick } from "./match.ts"; | |
| 17 | import { runBots } from "./bots.ts"; | |
| 18 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/interp.js"; | |
| 19 | import { makeLocal, stepLocal } from "../static/predict.js"; | |
| 20 | import { makeYaw, settle, told } from "../static/yaw.js"; | |
| 21 | ||
| 22 | const round2 = (n: number) => Math.round(n * 10) / 10; | |
| 23 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; | |
| 24 | ||
| 25 | /** A seeded wobble, so the run is the same every time. */ | |
| 26 | function rng(seed: number) { | |
| 27 | let s = seed | 0; | |
| 28 | return () => { | |
| 29 | s = (s + 0x6d2b79f5) | 0; | |
| 30 | let t = Math.imul(s ^ (s >>> 15), s | 1); | |
| 31 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); | |
| 32 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; | |
| 33 | }; | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * The wire as it was measured: 30 a second, p50 gap 33ms, p99 41–48ms, no | |
| 38 | * gap over 100ms. Each packet takes a base latency plus a wobble, and now | |
| 39 | * and then one is held a dozen milliseconds longer. | |
| 40 | */ | |
| 41 | function jitter(random: () => number): number { | |
| 42 | const u = random(); | |
| 43 | const v = random(); | |
| 44 | const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v); | |
| 45 | return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0); | |
| 46 | } | |
| 47 | ||
| 48 | const spreadOf = (steps: number[]) => { | |
| 49 | const mean = steps.reduce((s, x) => s + x, 0) / steps.length; | |
| 50 | const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length); | |
| 51 | return { mean, spread: sd / mean }; | |
| 52 | }; | |
| 53 | ||
| 54 | type Body = { id: string; x: number; y: number; angle: number }; | |
| 55 | type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string }; bodies: Body[] }; | |
| 56 | ||
| 57 | /** | |
| 58 | * A person walking east across open ground for two seconds, sending intent | |
| 59 | * every 50ms, hearing back thirty times a second over a jittery wire, and | |
| 60 | * drawn at sixty frames — with the input reaching the server one way late. | |
| 61 | */ | |
| 62 | function walkOwnBody(base = 0.02) { | |
| 63 | const random = rng(7); | |
| 64 | const state = newMatch(11); | |
| 65 | const me = state.players[0]!; | |
| 66 | me.bot = false; | |
| 67 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) { | |
| 68 | runBots(state, 1 / 30); | |
| 69 | tick(state, 1 / 30); | |
| 70 | } | |
| 71 | // Somewhere open: the middle of the map, facing east. | |
| 72 | me.x = 1000; | |
| 73 | me.y = 640; | |
| 74 | me.hp = 100; | |
| 75 | me.alive = true; | |
| 76 | me.haste = 0; | |
| 77 | me.slow = 0; | |
| 78 | me.weapon = "classic"; | |
| 79 | const packets: Packet[] = []; | |
| 80 | const inputs: { at: number; mx: number; my: number }[] = []; | |
| 81 | const held = { mx: 0, my: 0 }; | |
| 82 | const walkFrom = 0.3; | |
| 83 | const walkUntil = 1.6; | |
| 84 | // The server's clock: one tick every 33ms; snapshots leave right after. | |
| 85 | for (let k = 0; k * (1 / 30) < 2.2; k++) { | |
| 86 | const t = k / 30; | |
| 87 | while (inputs.length && inputs[0]!.at <= t) { | |
| 88 | const input = inputs.shift()!; | |
| 89 | setInput(me, { mx: input.mx, my: input.my, angle: 0 }); | |
| 90 | } | |
| 91 | // The browser's send timer is on its own clock: every 50ms, arriving one | |
| 92 | // way of latency later. | |
| 93 | for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) { | |
| 94 | if (s * 0.05 < t - 1 / 30) continue; | |
| 95 | const sentAt = s * 0.05; | |
| 96 | const moving = sentAt >= walkFrom && sentAt < walkUntil; | |
| 97 | inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 }); | |
| 98 | } | |
| 99 | inputs.sort((a, b) => a.at - b.at); | |
| 100 | tick(state, 1 / 30); | |
| 101 | packets.push({ | |
| 102 | at: t + base + jitter(random), | |
| 103 | stamp: 0, | |
| 104 | me: { id: me.id, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), alive: me.alive, phase: state.phase }, | |
| 105 | bodies: [], | |
| 106 | }); | |
| 107 | } | |
| 108 | packets.sort((a, b) => a.at - b.at); | |
| 109 | ||
| 110 | const timeline = makeTimeline(); | |
| 111 | const local = makeLocal(); | |
| 112 | let snap: Packet | null = null; | |
| 113 | let next = 0; | |
| 114 | let previous = 0; | |
| 115 | const steps: number[] = []; | |
| 116 | let back = 0; | |
| 117 | let anchors = 0; | |
| 118 | for (let f = 0; f / 60 < 2.2; f++) { | |
| 119 | const now = f / 60; | |
| 120 | while (next < packets.length && packets[next]!.at <= now) { | |
| 121 | snap = packets[next]!; | |
| 122 | snap.stamp = arrive(timeline, snap.at); | |
| 123 | next += 1; | |
| 124 | } | |
| 125 | if (!snap) continue; | |
| 126 | held.mx = now >= walkFrom && now < walkUntil ? 1 : 0; | |
| 127 | const before = local.anchored; | |
| 128 | stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null); | |
| 129 | // The first word about where you are is taken outright: that one is not a jump. | |
| 130 | if (before > 0) anchors += local.anchored - before; | |
| 131 | if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) { | |
| 132 | const step = local.x - previous; | |
| 133 | steps.push(step); | |
| 134 | back = Math.min(back, step); | |
| 135 | } | |
| 136 | previous = local.x; | |
| 137 | } | |
| 138 | return { steps, back, anchors, local, server: me }; | |
| 139 | } | |
| 140 | ||
| 141 | test("your own body, walking, takes even steps and never a backward one", () => { | |
| 142 | const { steps, back, anchors } = walkOwnBody(); | |
| 143 | const { mean, spread } = spreadOf(steps); | |
| 144 | console.log( | |
| 145 | `own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + | |
| 146 | `largest backward step ${back.toFixed(2)}, anchors ${anchors}`, | |
| 147 | ); | |
| 148 | // Running east at 250 a second is 4.17 a frame. | |
| 149 | expect(mean * 60).toBeGreaterThan(RUN * 0.9); | |
| 150 | expect(mean * 60).toBeLessThan(RUN * 1.1); | |
| 151 | // Even: the per-frame step does not beat against the wire's jitter. | |
| 152 | expect(spread).toBeLessThan(0.05); | |
| 153 | // And a step backwards is a stutter by definition. | |
| 154 | expect(back).toBeGreaterThan(-0.01); | |
| 155 | expect(anchors).toBe(0); | |
| 156 | }); | |
| 157 | ||
| 158 | test("starting and stopping never pulls you back, and the speed never dips a third", () => { | |
| 159 | // The first tenth of a second after the key goes down the server has not | |
| 160 | // heard yet, and the guess used to be yanked back to the server's still | |
| 161 | // body on each of the first two snapshots. Letting go, the server runs on | |
| 162 | // for the same lag and the guess must not be dragged back to meet it. | |
| 163 | const random = rng(3); | |
| 164 | const timeline = makeTimeline(); | |
| 165 | const local = makeLocal(); | |
| 166 | const me = { x: 1000, y: 640, angle: 0, alive: true }; | |
| 167 | const held = { mx: 0, my: 0 }; | |
| 168 | const hop = 0.03; // one way, plus the send timer's own phase | |
| 169 | let serverX = 1000; | |
| 170 | const packets: { at: number; x: number }[] = []; | |
| 171 | for (let k = 0; k / 30 < 2; k++) { | |
| 172 | const t = k / 30; | |
| 173 | // The server hears the key a hop after the next 50ms send. | |
| 174 | const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop; | |
| 175 | const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop; | |
| 176 | if (t > heardDown && t <= heardUp) serverX += RUN / 30; | |
| 177 | packets.push({ at: t + hop + jitter(random), x: round2(serverX) }); | |
| 178 | } | |
| 179 | let next = 0; | |
| 180 | let previous = 0; | |
| 181 | let back = 0; | |
| 182 | let slowest = Infinity; | |
| 183 | for (let f = 0; f / 60 < 2; f++) { | |
| 184 | const now = f / 60; | |
| 185 | while (next < packets.length && packets[next]!.at <= now) { | |
| 186 | me.x = packets[next]!.x; | |
| 187 | arrive(timeline, packets[next]!.at); | |
| 188 | next += 1; | |
| 189 | } | |
| 190 | if (next === 0) continue; | |
| 191 | held.mx = now >= 0.3 && now < 1.2 ? 1 : 0; | |
| 192 | stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null); | |
| 193 | const step = local.x - previous; | |
| 194 | if (now > 0.3 + 1 / 60) back = Math.min(back, step); | |
| 195 | if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step); | |
| 196 | previous = local.x; | |
| 197 | } | |
| 198 | console.log( | |
| 199 | `walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` + | |
| 200 | `settled ${(local.x - serverX).toFixed(1)} from the server`, | |
| 201 | ); | |
| 202 | expect(back).toBeGreaterThan(-0.01); | |
| 203 | expect(slowest).toBeGreaterThan((RUN / 60) * 0.67); | |
| 204 | // Where you stopped is where the server stops: the guess settles onto it. | |
| 205 | expect(Math.abs(local.x - serverX)).toBeLessThan(1); | |
| 206 | }); | |
| 207 | ||
| 208 | /** | |
| 209 | * Everybody else: a bot walking, heard about thirty times a second over the | |
| 210 | * same wire, drawn a little in the past between the samples on hand. | |
| 211 | */ | |
| 212 | function watchBots() { | |
| 213 | const random = rng(5); | |
| 214 | const state = newMatch(23); | |
| 215 | const step = () => { | |
| 216 | runBots(state, 1 / 30); | |
| 217 | tick(state, 1 / 30); | |
| 218 | }; | |
| 219 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); | |
| 220 | const packets: { at: number; bodies: Body[] }[] = []; | |
| 221 | for (let k = 0; k < 90; k++) { | |
| 222 | step(); | |
| 223 | packets.push({ | |
| 224 | at: k / 30 + 0.02 + jitter(random), | |
| 225 | bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), | |
| 226 | }); | |
| 227 | } | |
| 228 | const timeline = makeTimeline(); | |
| 229 | const paths = new Map<string, { x: number; y: number }[]>(); | |
| 230 | const delays: number[] = []; | |
| 231 | let ahead = 0; | |
| 232 | let frames = 0; | |
| 233 | let next = 0; | |
| 234 | for (let f = 0; f / 60 < 2.8; f++) { | |
| 235 | const now = f / 60; | |
| 236 | while (next < packets.length && packets[next]!.at <= now) { | |
| 237 | const stamp = arrive(timeline, packets[next]!.at); | |
| 238 | for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); | |
| 239 | next += 1; | |
| 240 | } | |
| 241 | if (next < 4) continue; | |
| 242 | const drawAt = renderTime(timeline, now); | |
| 243 | delays.push(now - drawAt); | |
| 244 | frames += 1; | |
| 245 | if (drawAt > timeline.stamp) ahead += 1; | |
| 246 | for (const p of packets[next - 1]!.bodies) { | |
| 247 | const at = bodyAt(timeline, p, drawAt); | |
| 248 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); | |
| 249 | } | |
| 250 | } | |
| 251 | const walked = [...paths.values()] | |
| 252 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) | |
| 253 | .map((steps) => steps.filter((s) => s > 0.001)) | |
| 254 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; | |
| 255 | return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline }; | |
| 256 | } | |
| 257 | ||
| 258 | test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => { | |
| 259 | const { walked, restarts, delays, ahead, frames } = watchBots(); | |
| 260 | const { mean, spread } = spreadOf(walked); | |
| 261 | const dmin = Math.min(...delays); | |
| 262 | const dmax = Math.max(...delays); | |
| 263 | console.log( | |
| 264 | `others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + | |
| 265 | `restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` + | |
| 266 | `drawn past the newest sample ${ahead}/${frames} frames`, | |
| 267 | ); | |
| 268 | expect(walked.length).toBeGreaterThan(40); | |
| 269 | expect(mean * 60).toBeGreaterThan(150); | |
| 270 | expect(spread).toBeLessThan(0.08); | |
| 271 | // A body that was in sight all along never jumps to start again. | |
| 272 | expect(restarts).toBe(0); | |
| 273 | // The moment drawn is never later than the newest word: no guessing ahead. | |
| 274 | expect(ahead).toBe(0); | |
| 275 | }); | |
| 276 | ||
| 277 | test("the render clock never steps backwards or stalls, whatever the wire does", () => { | |
| 278 | const random = rng(9); | |
| 279 | const timeline = makeTimeline(); | |
| 280 | let arrival = 0.02; | |
| 281 | let drawn = 0; | |
| 282 | let worstShort = Infinity; | |
| 283 | let worstLong = 0; | |
| 284 | for (let f = 0; f / 60 < 5; f++) { | |
| 285 | const now = f / 60; | |
| 286 | while (arrival <= now) { | |
| 287 | arrive(timeline, arrival); | |
| 288 | arrival += 1 / 30 + jitter(random) - 0.002; | |
| 289 | } | |
| 290 | const at = renderTime(timeline, now); | |
| 291 | if (f > 30 && drawn > 0) { | |
| 292 | worstShort = Math.min(worstShort, at - drawn); | |
| 293 | worstLong = Math.max(worstLong, at - drawn); | |
| 294 | } | |
| 295 | drawn = at; | |
| 296 | } | |
| 297 | console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`); | |
| 298 | // Within a few percent of real time, every frame: 16.67ms give or take. | |
| 299 | expect(worstShort * 60).toBeGreaterThan(0.97); | |
| 300 | expect(worstLong * 60).toBeLessThan(1.03); | |
| 301 | }); | |
| 302 | ||
| 303 | test("a burst of snapshots does not walk the even clock into the future", () => { | |
| 304 | // A tab back from the background is handed everything it missed at once. | |
| 305 | // The clock those samples are filed on may lead the arrivals by half a | |
| 306 | // beat and no more, or the sent angles age out and every echo of your own | |
| 307 | // view comes back as a correction. | |
| 308 | const timeline = makeTimeline(); | |
| 309 | let at = 10; | |
| 310 | for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); | |
| 311 | const before = timeline.stamp; | |
| 312 | for (let i = 0; i < 150; i++) { | |
| 313 | const stamp = arrive(timeline, (at += 0.0002)); | |
| 314 | expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9); | |
| 315 | expect(stamp).toBeGreaterThanOrEqual(before); | |
| 316 | } | |
| 317 | // And once the wire is itself again, the beat is back on it. | |
| 318 | for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); | |
| 319 | expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01); | |
| 320 | }); | |
| 321 | ||
| 322 | test("a round start points you at the site once, and nothing stomps it a tick later", () => { | |
| 323 | const state = newMatch(31); | |
| 324 | const me = state.players[0]!; | |
| 325 | me.bot = false; | |
| 326 | const yaw = makeYaw(); | |
| 327 | let corrections = 0; | |
| 328 | let worstRound = 0; | |
| 329 | let at = 0; | |
| 330 | const hear = () => { | |
| 331 | const before = yaw.angle; | |
| 332 | settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at); | |
| 333 | if (yaw.angle !== before) corrections += 1; | |
| 334 | }; | |
| 335 | hear(); | |
| 336 | // A dozen round starts, each followed by a second of ticks with the client | |
| 337 | // echoing back exactly what it was pointed at, the way the input timer | |
| 338 | // does — and each echo reaching the server a tick after it was sent. | |
| 339 | const inFlight: number[] = []; | |
| 340 | for (let round = 0; round < 12; round++) { | |
| 341 | const were = corrections; | |
| 342 | startRound(state); | |
| 343 | hear(); // the snapshot that follows the tick which started the round | |
| 344 | for (let k = 0; k < 30; k++) { | |
| 345 | at += 1 / 30; | |
| 346 | if (inFlight.length) setInput(me, { angle: inFlight.shift()! }); | |
| 347 | if (k % 2 === 0) { | |
| 348 | told(yaw, yaw.angle, at); | |
| 349 | inFlight.push(yaw.angle); | |
| 350 | } | |
| 351 | tick(state, 1 / 30); | |
| 352 | hear(); | |
| 353 | } | |
| 354 | // Facing the site the server put us at, still, a second later. | |
| 355 | const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; | |
| 356 | const want = Math.atan2(centre.y - me.y, centre.x - me.x); | |
| 357 | expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11); | |
| 358 | worstRound = Math.max(worstRound, corrections - were); | |
| 359 | } | |
| 360 | console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`); | |
| 361 | // At most one a round — the spawn — and never a second one to angle zero. | |
| 362 | expect(worstRound).toBeLessThanOrEqual(1); | |
| 363 | }); |
src/gun.test.ts +26 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { fade, firedNow, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js"; | |
| 2 | import { fade, firedNow, gunFire, gunSnapshot, gunTick, hitMarked, makeGun } from "../static/gun.js"; | |
| 3 | 3 | |
| 4 | 4 | const snap = (over: Record<string, unknown> = {}, shots: unknown[] = []) => ({ |
| 5 | 5 | me: { x: 100, y: 100, ammo: 25, reloading: false, alive: true, ...over }, |
| @@ -44,6 +44,31 @@ test("a landed shot marks, and the mark fades on its own clock", () => { | ||
| 44 | 44 | expect(gun.mark).toBe(0); |
| 45 | 45 | }); |
| 46 | 46 | |
| 47 | test("pulling the trigger kicks the gun now, and the snapshot's word is not a second kick", () => { | |
| 48 | const gun = makeGun(); | |
| 49 | gunSnapshot(gun, snap()); | |
| 50 | expect(gunFire(gun, snap())).toBe(true); | |
| 51 | expect(gun.kick).toBe(1); | |
| 52 | // The kick is well under way when the server's word arrives: no restart. | |
| 53 | gunTick(gun, 0.08); | |
| 54 | const shown = gun.kick; | |
| 55 | gunSnapshot(gun, snap({ ammo: 24 })); | |
| 56 | expect(gun.kick).toBe(shown); | |
| 57 | // A second shot the server fired on its own (a held trigger) still kicks. | |
| 58 | gunSnapshot(gun, snap({ ammo: 23 })); | |
| 59 | expect(gun.kick).toBe(1); | |
| 60 | // No kick with nothing to fire: dead, reloading, or empty. | |
| 61 | expect(gunFire(makeGun(), snap({ alive: false }))).toBe(false); | |
| 62 | expect(gunFire(makeGun(), snap({ reloading: true }))).toBe(false); | |
| 63 | expect(gunFire(makeGun(), snap({ ammo: 0 }))).toBe(false); | |
| 64 | // A kick the server never answered is forgotten once it has faded. | |
| 65 | const refused = makeGun(); | |
| 66 | gunFire(refused, snap()); | |
| 67 | gunTick(refused, 1); | |
| 68 | gunSnapshot(refused, snap()); | |
| 69 | expect(refused.owed).toBe(0); | |
| 70 | }); | |
| 71 | ||
| 47 | 72 | test("fade never goes below nothing", () => { |
| 48 | 73 | expect(fade(0.1, 10, 0.2)).toBe(0); |
| 49 | 74 | }); |
src/match.ts +6 −1
| @@ -235,12 +235,17 @@ export function startRound(state: MatchState, first = false): void { | ||
| 235 | 235 | player.goal = null; |
| 236 | 236 | player.think = 0; |
| 237 | 237 | player.aim = emptyTrack(); |
| 238 | player.input = emptyInput(); | |
| 239 | 238 | // Basics are bought fresh every round; the signature comes back free. |
| 240 | 239 | player.charges = { c: 0, q: 0, e: 1, x: 0 }; |
| 241 | 240 | player.cooldown = { c: 0, q: 0, e: 0, x: 0 }; |
| 242 | 241 | if (first) player.credits = START_CREDITS; |
| 243 | 242 | placeAtSpawn(state, player, byTeam[player.team]++); |
| 243 | // The keys are let go of, but the facing the spawn gave is kept as the | |
| 244 | // input's own: an empty input's angle is zero, and the next tick copies | |
| 245 | // the input's angle onto the player, so a round used to point everybody | |
| 246 | // at the site and then swing them east a tick later — a camera that | |
| 247 | // jumped twice in the first hundred milliseconds of every round. | |
| 248 | player.input = { ...emptyInput(), angle: player.angle }; | |
| 244 | 249 | } |
| 245 | 250 | giveSpike(state); |
| 246 | 251 | } |
static/gun.js +23 −2
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | * shots the server sent, the same ones the top-down view draws. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | export const makeGun = () => ({ kick: 0, mark: 0, ammo: null }); | |
| 9 | /** `owed` is how many kicks have been shown ahead of the server saying a shot happened. */ | |
| 10 | export const makeGun = () => ({ kick: 0, mark: 0, ammo: null, owed: 0 }); | |
| 10 | 11 | |
| 11 | 12 | /** Recoil and the hit marker both fade the same way: fast, and to zero. */ |
| 12 | 13 | export const fade = (value, dt, life) => Math.max(0, value - dt / life); |
| @@ -24,9 +25,29 @@ export function hitMarked(shots, me) { | ||
| 24 | 25 | return false; |
| 25 | 26 | } |
| 26 | 27 | |
| 28 | /** | |
| 29 | * The trigger was pulled here, now. If the gun can fire — alive, loaded, not | |
| 30 | * reloading — it kicks at once rather than a round trip later, and the kick | |
| 31 | * the server's snapshot would have given for the same shot is owed and not | |
| 32 | * shown twice. Whether the shot lands, and whether it even happened, is the | |
| 33 | * server's alone: a kick is the hand, not the bullet. | |
| 34 | */ | |
| 35 | export function gunFire(gun, snap) { | |
| 36 | if (!snap.me.alive || snap.me.reloading || snap.me.ammo <= 0) return false; | |
| 37 | if (gun.kick > 0.5) return false; // a held trigger, not a new pull | |
| 38 | gun.kick = 1; | |
| 39 | gun.owed += 1; | |
| 40 | return true; | |
| 41 | } | |
| 42 | ||
| 27 | 43 | /** Fold a fresh snapshot into the gun's state: kick on a shot, mark on a hit. */ |
| 28 | 44 | export function gunSnapshot(gun, snap) { |
| 29 | if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) gun.kick = 1; | |
| 45 | if (firedNow(gun.ammo, snap.me.ammo, snap.me.reloading)) { | |
| 46 | if (gun.owed > 0) gun.owed -= 1; | |
| 47 | else gun.kick = 1; | |
| 48 | } | |
| 49 | // A kick owed for longer than a shot could take to come back was refused. | |
| 50 | if (gun.owed > 0 && gun.kick === 0) gun.owed = 0; | |
| 30 | 51 | if (snap.me.alive && hitMarked(snap.shots, snap.me)) gun.mark = 1; |
| 31 | 52 | gun.ammo = snap.me.ammo; |
| 32 | 53 | } |
static/interp.js +85 −10
| @@ -34,7 +34,15 @@ export const LAG_MAX = 0.13; | ||
| 34 | 34 | /** How many samples a body keeps: enough to bracket the delay, and no more. */ |
| 35 | 35 | export const KEEP = 4; |
| 36 | 36 | /** How fast the measured interval follows the arrivals. */ |
| 37 | const INTERVAL_PULL = 0.25; | |
| 37 | const INTERVAL_PULL = 0.1; | |
| 38 | /** How fast the even clock snapshots are filed under is pulled toward the arrivals. */ | |
| 39 | const STAMP_PULL = 0.1; | |
| 40 | /** How much of the even clock's lateness is read as the interval being wrong. */ | |
| 41 | const INTERVAL_TRIM = 0.02; | |
| 42 | /** How far the moment drawn may run fast or slow to reach its delay: a share of real time. */ | |
| 43 | export const SLEW = 0.02; | |
| 44 | /** A render clock this far off its mark is not late, it is stopped: start it again. */ | |
| 45 | const RESYNC = 0.25; | |
| 38 | 46 | /** A gap this long means the body was gone, not slow: start it again. */ |
| 39 | 47 | export const GONE = 0.4; |
| 40 | 48 | /** Faster than anything can run: a respawn, or a body back from nowhere. */ |
| @@ -45,28 +53,90 @@ const clamp = (n, low, high) => (n < low ? low : n > high ? high : n); | ||
| 45 | 53 | /** The short way round from one angle to another, in (-π, π]. */ |
| 46 | 54 | export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| 47 | 55 | |
| 48 | /** One timeline for the whole snapshot stream: the bodies, and the rate. */ | |
| 49 | export const makeTimeline = (interval = 1 / 15) => ({ bodies: new Map(), interval, at: 0 }); | |
| 56 | /** | |
| 57 | * One timeline for the whole snapshot stream: the bodies, the rate, the even | |
| 58 | * clock (`stamp`) and the moment being drawn (`drawAt`). `restarts` counts | |
| 59 | * the times a body was started again rather than walked: every one is a jump. | |
| 60 | */ | |
| 61 | export const makeTimeline = (interval = 1 / 30) => ({ | |
| 62 | bodies: new Map(), | |
| 63 | interval, | |
| 64 | at: 0, | |
| 65 | stamp: 0, | |
| 66 | drawAt: 0, | |
| 67 | drawnNow: 0, | |
| 68 | restarts: 0, | |
| 69 | }); | |
| 50 | 70 | |
| 51 | 71 | /** |
| 52 | 72 | * A snapshot landed at `at` seconds. The gap since the last one is what the |
| 53 | 73 | * render delay is built out of; a gap wilder than any real rate (a tab that |
| 54 | 74 | * was in the background, a reconnect) says nothing about the rate and is |
| 55 | 75 | * left out of it. |
| 76 | * | |
| 77 | * Returns the time the snapshot is filed under, which is not the time it | |
| 78 | * landed. The server sends thirty a second, evenly; the network hands them | |
| 79 | * over in a rhythm of its own, a few milliseconds early or late, sometimes | |
| 80 | * two nearly together. A sample filed under its arrival time carries that | |
| 81 | * wobble into the walk between samples — a body drawn between a sample that | |
| 82 | * came early and one that came late moves slow, then fast — and into the | |
| 83 | * age of your own snapshot, which is how far ahead your own keys are | |
| 84 | * carried. So arrivals are filed on an even clock instead: one interval | |
| 85 | * after the last, pulled a tenth of the way toward when they really came so | |
| 86 | * it never drifts. A rate is not a rhythm; this is the rhythm. | |
| 56 | 87 | */ |
| 57 | 88 | export function arrive(timeline, at) { |
| 58 | 89 | const gap = at - timeline.at; |
| 59 | if (timeline.at > 0 && gap > 1 / 120 && gap < 0.5) | |
| 60 | timeline.interval += (gap - timeline.interval) * INTERVAL_PULL; | |
| 90 | const steady = timeline.at > 0 && gap > 1 / 120 && gap < 0.5; | |
| 91 | if (steady) timeline.interval += (gap - timeline.interval) * INTERVAL_PULL; | |
| 92 | const expected = timeline.stamp + timeline.interval; | |
| 93 | const off = at - expected; | |
| 94 | // Within an interval of the beat, keep the beat; further out (a pause, a | |
| 95 | // reconnect, the rate itself changing) the arrival is the only clock there is. | |
| 96 | let stamp = at; | |
| 97 | if (timeline.at > 0 && gap < 0.5 && Math.abs(off) < timeline.interval) { | |
| 98 | stamp = expected + off * STAMP_PULL; | |
| 99 | // Arrivals that keep landing on the same side of the beat mean the beat | |
| 100 | // is wrong, not late: trim the interval by a little of the lateness. | |
| 101 | timeline.interval += off * INTERVAL_TRIM; | |
| 102 | } | |
| 103 | // A sample is never filed before the one before it, and never more than | |
| 104 | // half a beat before it actually came: a burst of snapshots (a tab coming | |
| 105 | // back from the background) must not walk the clock off into the future. | |
| 106 | if (stamp <= timeline.stamp) stamp = timeline.stamp + timeline.interval / 4; | |
| 107 | stamp = Math.max(Math.min(stamp, at + timeline.interval / 2), timeline.stamp); | |
| 61 | 108 | timeline.at = at; |
| 62 | return timeline; | |
| 109 | timeline.stamp = stamp; | |
| 110 | return stamp; | |
| 63 | 111 | } |
| 64 | 112 | |
| 65 | 113 | /** How far behind the present this timeline is drawn, in seconds. */ |
| 66 | 114 | export const delay = (timeline) => clamp(timeline.interval * LAG, LAG_MIN, LAG_MAX); |
| 67 | 115 | |
| 68 | /** The moment being drawn, given the clock now. */ | |
| 69 | export const renderTime = (timeline, now) => now - delay(timeline); | |
| 116 | /** | |
| 117 | * The moment being drawn, given the clock now. | |
| 118 | * | |
| 119 | * Not simply `now - delay`: the delay follows the measured rate, and a delay | |
| 120 | * that moves between frames moves the moment being drawn with it, so a | |
| 121 | * frame after a late snapshot drew twelve milliseconds of motion and the | |
| 122 | * next nineteen — the beat of the wire showing through as a stutter. The | |
| 123 | * moment drawn now advances at real time, and creeps toward where the delay | |
| 124 | * says it should be at no more than two percent of that: a speed nobody can | |
| 125 | * see. Only a clock a quarter second off — a tab that was in the background, | |
| 126 | * the first frame — is started again where it belongs. | |
| 127 | */ | |
| 128 | export function renderTime(timeline, now) { | |
| 129 | const want = now - delay(timeline); | |
| 130 | const dt = now - timeline.drawnNow; | |
| 131 | timeline.drawnNow = now; | |
| 132 | const ahead = timeline.drawAt + dt; | |
| 133 | if (timeline.drawAt === 0 || dt <= 0 || dt > RESYNC || Math.abs(want - ahead) > RESYNC) { | |
| 134 | timeline.drawAt = want; | |
| 135 | return want; | |
| 136 | } | |
| 137 | timeline.drawAt = ahead + clamp(want - ahead, -dt * SLEW, dt * SLEW); | |
| 138 | return timeline.drawAt; | |
| 139 | } | |
| 70 | 140 | |
| 71 | 141 | const mark = (at, body) => ({ t: at, x: body.x, y: body.y, angle: body.angle }); |
| 72 | 142 | |
| @@ -87,8 +157,13 @@ export function feed(timeline, id, body, at) { | ||
| 87 | 157 | const last = track.marks[track.marks.length - 1]; |
| 88 | 158 | if (at <= last.t) return timeline; |
| 89 | 159 | const gap = at - last.t; |
| 90 | const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * gap + 4; | |
| 91 | if (gap > GONE || jumped) track.marks.length = 0; | |
| 160 | // Two samples filed close together are still a tick apart on the server, | |
| 161 | // so a body is allowed at least an interval's run between them. | |
| 162 | const jumped = Math.hypot(body.x - last.x, body.y - last.y) > TOP_SPEED * Math.max(gap, timeline.interval) + 4; | |
| 163 | if (gap > GONE || jumped) { | |
| 164 | track.marks.length = 0; | |
| 165 | timeline.restarts += 1; | |
| 166 | } | |
| 92 | 167 | track.marks.push(mark(at, body)); |
| 93 | 168 | if (track.marks.length > KEEP) track.marks.shift(); |
| 94 | 169 | return timeline; |
static/predict.js +89 −12
| @@ -23,8 +23,22 @@ export const SNAP = 40; | ||
| 23 | 23 | export const PULL = 12; |
| 24 | 24 | /** How far ahead of a snapshot your own input is ever carried, in seconds. */ |
| 25 | 25 | export const LEAD_MAX = 0.25; |
| 26 | /** | |
| 27 | * How long your own keys take to show in the server's answer: a send every | |
| 28 | * 50ms, a hop each way, a tick. The drawn body is allowed this much of a | |
| 29 | * lead over the answer without being called wrong, in seconds of running — | |
| 30 | * and a disagreement inside it is leaned off gently rather than at PULL, | |
| 31 | * because most of what is inside it is not a disagreement at all but the | |
| 32 | * lag itself, which a hard pull turns into a yank at every start and stop. | |
| 33 | */ | |
| 34 | export const LEAD_SLACK = 0.08; | |
| 35 | export const SOFT_PULL = 2; | |
| 26 | 36 | |
| 27 | export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0 }); | |
| 37 | /** | |
| 38 | * `anchored` counts the times the server's word was taken outright: every one | |
| 39 | * is a jump. `still` is how long no key has been held, in seconds. | |
| 40 | */ | |
| 41 | export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0, anchored: 0, still: 0 }); | |
| 28 | 42 | |
| 29 | 43 | /** Take the server's word for it outright. */ |
| 30 | 44 | export function anchor(local, at) { |
| @@ -32,6 +46,7 @@ export function anchor(local, at) { | ||
| 32 | 46 | local.y = at.y; |
| 33 | 47 | local.ready = true; |
| 34 | 48 | local.gap = 0; |
| 49 | local.anchored += 1; | |
| 35 | 50 | return local; |
| 36 | 51 | } |
| 37 | 52 | |
| @@ -78,32 +93,59 @@ export function project(at, mx, my, speed, age, world) { | ||
| 78 | 93 | * Lean the drawn body toward the server's answer. A small disagreement — |
| 79 | 94 | * the width of the guess, a shot of lag — is walked off over a moment; a |
| 80 | 95 | * large one is not a disagreement but a different place, and is taken. |
| 96 | * | |
| 97 | * With keys held (`mx, my`), the part of the gap along the way you are going | |
| 98 | * is the lag when the drawn body is ahead: the server has not heard the keys | |
| 99 | * yet. Inside `slack` that is left exactly alone — pulling it is what made | |
| 100 | * every start a yank and every stop a drift. Across the way you are going, | |
| 101 | * or with the server ahead, a gap is real and is leaned off at PULL. Having | |
| 102 | * just stopped, a gap inside the slack is the server still catching up to | |
| 103 | * where you stopped: held for the lag, then leaned off gently enough not to | |
| 104 | * show. | |
| 81 | 105 | */ |
| 82 | export function reconcile(local, server, dt) { | |
| 106 | export function reconcile(local, server, dt, mx = 0, my = 0, slack = 0) { | |
| 83 | 107 | if (!local.ready) return anchor(local, server); |
| 84 | 108 | const dx = server.x - local.x; |
| 85 | 109 | const dy = server.y - local.y; |
| 86 | 110 | local.gap = Math.hypot(dx, dy); |
| 87 | 111 | if (local.gap > SNAP) return anchor(local, server); |
| 88 | const k = 1 - Math.exp(-PULL * Math.max(0, dt)); | |
| 89 | local.x += dx * k; | |
| 90 | local.y += dy * k; | |
| 112 | const hard = 1 - Math.exp(-PULL * Math.max(0, dt)); | |
| 113 | const len = Math.hypot(mx, my); | |
| 114 | if (len === 0) { | |
| 115 | if (local.gap <= slack && local.still < LEAD_SLACK) return local; | |
| 116 | const k = local.gap > slack ? hard : 1 - Math.exp(-SOFT_PULL * Math.max(0, dt)); | |
| 117 | local.x += dx * k; | |
| 118 | local.y += dy * k; | |
| 119 | return local; | |
| 120 | } | |
| 121 | const ux = mx / len; | |
| 122 | const uy = my / len; | |
| 123 | const along = dx * ux + dy * uy; // positive: the server is ahead of the drawing | |
| 124 | const acrossX = dx - along * ux; | |
| 125 | const acrossY = dy - along * uy; | |
| 126 | local.x += acrossX * hard; | |
| 127 | local.y += acrossY * hard; | |
| 128 | if (along > 0 || -along > slack) { | |
| 129 | local.x += ux * along * hard; | |
| 130 | local.y += uy * along * hard; | |
| 131 | } | |
| 91 | 132 | return local; |
| 92 | 133 | } |
| 93 | 134 | |
| 94 | 135 | /** |
| 95 | * Never past the answer. The guess may fill the gap to where the server's | |
| 96 | * word and your own held keys put you and no further: left to run, a guess | |
| 97 | * that moves faster than its target is corrected finds its own equilibrium | |
| 98 | * somewhere beyond it, which is a claim about a position rather than a | |
| 99 | * drawing of one. | |
| 136 | * Never further past the answer than the lag. The guess may fill the gap to | |
| 137 | * where the server's word and your own held keys put you, plus `slack` — | |
| 138 | * the distance your keys travel before the server has heard them — and no | |
| 139 | * further: left to run, a guess that moves faster than its target is | |
| 140 | * corrected finds its own equilibrium somewhere beyond it, which is a claim | |
| 141 | * about a position rather than a drawing of one. | |
| 100 | 142 | */ |
| 101 | export function holdBack(local, target, mx, my) { | |
| 143 | export function holdBack(local, target, mx, my, slack = 0) { | |
| 102 | 144 | const len = Math.hypot(mx, my); |
| 103 | 145 | if (len === 0) return local; |
| 104 | 146 | const dx = mx / len; |
| 105 | 147 | const dy = my / len; |
| 106 | const over = (local.x - target.x) * dx + (local.y - target.y) * dy; | |
| 148 | const over = (local.x - target.x) * dx + (local.y - target.y) * dy - slack; | |
| 107 | 149 | if (over > 0) { |
| 108 | 150 | local.x -= dx * over; |
| 109 | 151 | local.y -= dy * over; |
| @@ -113,3 +155,38 @@ export function holdBack(local, target, mx, my) { | ||
| 113 | 155 | |
| 114 | 156 | /** A body drawn inside a wall is worse than a body drawn late. */ |
| 115 | 157 | export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile); |
| 158 | ||
| 159 | /** | |
| 160 | * One frame of your own body, the whole of it: the server's answer `me` | |
| 161 | * carried `age` seconds forward by the keys `held`, the drawn body leaned | |
| 162 | * onto that, advanced a frame, and never drawn past it. | |
| 163 | * | |
| 164 | * A lean across a corner can put the drawn body inside a wall, and `refused` | |
| 165 | * is the caller's word for anywhere else the server would not let you stand | |
| 166 | * (the spawn pen in the buy phase). Both used to take the server's answer, | |
| 167 | * which is a jump: pressed against a wall and strafing, that fired most | |
| 168 | * frames and read as a stutter. Holding the last good place is invisible and | |
| 169 | * just as honest — the next snapshot corrects it anyway. Anchoring is kept | |
| 170 | * for the one case where holding still leaves the body in a wall. | |
| 171 | */ | |
| 172 | export function stepLocal(local, me, held, speed, age, dt, world, refused) { | |
| 173 | const target = project(me, held.mx, held.my, speed, age, world); | |
| 174 | const wasX = local.x; | |
| 175 | const wasY = local.y; | |
| 176 | const slack = speed * LEAD_SLACK; | |
| 177 | local.still = held.mx === 0 && held.my === 0 ? local.still + dt : 0; | |
| 178 | reconcile(local, target, dt, held.mx, held.my, slack); | |
| 179 | advance(local, held.mx, held.my, speed, dt, world); | |
| 180 | holdBack(local, target, held.mx, held.my, slack); | |
| 181 | if (insideWall(local, world) || (refused !== null && refused(local.x, local.y))) { | |
| 182 | local.x = wasX; | |
| 183 | local.y = wasY; | |
| 184 | // Holding is for a step the server would refuse, never for the server's | |
| 185 | // own answer: a barrier dropped on somebody standing still leaves them | |
| 186 | // inside it on the server too, and a hold that outranked that would keep | |
| 187 | // the drawn body wherever it last stood, a room away, for as long as the | |
| 188 | // barrier lasted. Past the snap distance, or in a wall, the answer wins. | |
| 189 | if (insideWall(local, world) || Math.hypot(local.x - me.x, local.y - me.y) > SNAP) anchor(local, me); | |
| 190 | } | |
| 191 | return local; | |
| 192 | } |
static/val.js +50 −27
| @@ -15,8 +15,8 @@ import { makeBeeper } from "./beep.js"; | ||
| 15 | 15 | import { makeBudget, measure } from "./budget.js"; |
| 16 | 16 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./interp.js"; |
| 17 | 17 | import { BODY_R, moveSpeed } from "../src/glide.ts"; |
| 18 | import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js"; | |
| 19 | import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js"; | |
| 18 | import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./gun.js"; | |
| 19 | import { makeLocal, stepLocal } from "./predict.js"; | |
| 20 | 20 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./presence.js"; |
| 21 | 21 | import { choosePrompt, REACH } from "./prompt.js"; |
| 22 | 22 | import { renderSize, WIDTHS } from "./resolution.js"; |
| @@ -110,22 +110,25 @@ function connect() { | ||
| 110 | 110 | const data = JSON.parse(event.data); |
| 111 | 111 | if (data.t !== "s") return; |
| 112 | 112 | snap = data; |
| 113 | snapAt = performance.now(); | |
| 113 | const landed = performance.now(); | |
| 114 | 114 | // No seat, no `me`, no players. Everything below reads a body out of the |
| 115 | 115 | // snapshot, so a watching connection takes the other path entirely. |
| 116 | 116 | if (watching(data)) return void paintWatch(data); |
| 117 | 117 | // Timed samples for every body in it, ours included: the drawing walks |
| 118 | // between them rather than chasing the newest one. | |
| 119 | arrive(timeline, snapAt / 1000); | |
| 118 | // between them rather than chasing the newest one. The time a sample is | |
| 119 | // filed under is the wire's even beat, not the moment this one happened | |
| 120 | // to land — see interp.js. Your own snapshot's age is read off the same | |
| 121 | // beat, so the lead your keys are carried by does not wobble with it. | |
| 122 | snapAt = arrive(timeline, landed / 1000) * 1000; | |
| 120 | 123 | // Between rounds everybody is teleported to a spawn, so no sighting |
| 121 | 124 | // before that says anything about where anybody is now. |
| 122 | 125 | if (data.phase !== "live") forget(presence); |
| 123 | else sighted(presence, data.players, snapAt / 1000); | |
| 126 | else sighted(presence, data.players, landed / 1000); | |
| 124 | 127 | feed(timeline, data.me.id, data.me, snapAt / 1000); |
| 125 | 128 | for (const player of data.players) feed(timeline, player.id, player, snapAt / 1000); |
| 126 | 129 | // The server's angle is taken only when it is a correction, never as the |
| 127 | 130 | // routine echo of the angle we sent it a moment ago. |
| 128 | settle(head, data.me, snapAt / 1000); | |
| 131 | settle(head, data.me, landed / 1000); | |
| 129 | 132 | gunSnapshot(gun, data); |
| 130 | 133 | paintHud(data); |
| 131 | 134 | maybeHelp(data); |
| @@ -215,7 +218,12 @@ addEventListener("mousemove", (event) => { | ||
| 215 | 218 | if (locked) turn(head, event.movementX * LOOK); |
| 216 | 219 | }); |
| 217 | 220 | addEventListener("mousedown", (event) => { |
| 218 | if (locked && event.button === 0) firing = true; | |
| 221 | if (locked && event.button === 0) { | |
| 222 | firing = true; | |
| 223 | // The gun kicks now, not when the server's word comes back: the shot is | |
| 224 | // still the server's to fire and to land, this is the hand moving. | |
| 225 | if (snap && !watching(snap)) gunFire(gun, snap); | |
| 226 | } | |
| 219 | 227 | }); |
| 220 | 228 | addEventListener("mouseup", (event) => { |
| 221 | 229 | if (locked && event.button === 0) firing = false; |
| @@ -251,6 +259,7 @@ view.addEventListener("pointerdown", (event) => { | ||
| 251 | 259 | } |
| 252 | 260 | pointer = canvasPoint(event); |
| 253 | 261 | firing = true; |
| 262 | if (snap && !watching(snap)) gunFire(gun, snap); | |
| 254 | 263 | }); |
| 255 | 264 | view.addEventListener("pointermove", (event) => { |
| 256 | 265 | if (stick && event.pointerId === stick.id) { |
| @@ -383,6 +392,20 @@ const outsidePen = (x, y) => { | ||
| 383 | 392 | return best > 120; |
| 384 | 393 | }; |
| 385 | 394 | |
| 395 | /** | |
| 396 | * A wall an ability put up is as solid as the map's, and the snapshot carries | |
| 397 | * every one of them. Without this the guess ran into a barrier the map did | |
| 398 | * not know about, and the drawn body stood inside it until the server's | |
| 399 | * answer pulled it out. | |
| 400 | */ | |
| 401 | const insideBarrier = (x, y) => { | |
| 402 | for (const zone of snap.zones) if (zone.k === "wall" && zone.f <= 0 && Math.hypot(zone.x - x, zone.y - y) < zone.r + BODY_R) return true; | |
| 403 | return false; | |
| 404 | }; | |
| 405 | ||
| 406 | /** Anywhere the server would refuse to let you stand right now. */ | |
| 407 | const refused = (x, y) => outsidePen(x, y) || insideBarrier(x, y); | |
| 408 | ||
| 386 | 409 | /** The server's answer, caught up to now, then this frame of your own keys. */ |
| 387 | 410 | function predict(dt, now) { |
| 388 | 411 | if (!predicting()) { |
| @@ -394,26 +417,18 @@ function predict(dt, now) { | ||
| 394 | 417 | const speed = moveSpeed({ |
| 395 | 418 | haste: snap.me.haste > 0, |
| 396 | 419 | slow: snap.me.slow > 0, |
| 397 | stopped: snap.me.planting > 0 || snap.me.defusing, | |
| 420 | // Nobody moves once a round is decided: the server's tick returns before | |
| 421 | // it moves anybody, and a guess that kept walking ran a quarter second | |
| 422 | // into nothing and waited there for the next round to cut back. | |
| 423 | stopped: snap.me.planting > 0 || snap.me.defusing || snap.phase === "end" || snap.phase === "over", | |
| 398 | 424 | heavy: snap.me.weapon === "operator", |
| 399 | 425 | }); |
| 400 | 426 | // The snapshot is a moment old and the keys held since have not reached |
| 401 | // it; carry it that far forward and lean the drawn body onto it. | |
| 402 | const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world); | |
| 403 | const was = { x: local.x, y: local.y }; | |
| 404 | reconcile(local, target, dt); | |
| 405 | advance(local, held.mx, held.my, speed, dt, world); | |
| 406 | holdBack(local, target, held.mx, held.my); | |
| 407 | // A lean across a corner can put the drawn body inside a wall, and walking | |
| 408 | // out of the spawn pen is refused by the server outright. Both used to take | |
| 409 | // the server's answer, which is a jump: pressed against a wall and strafing, | |
| 410 | // that fired most frames and read as a stutter. Holding the last good place | |
| 411 | // is invisible and just as honest — the next snapshot corrects it anyway. | |
| 412 | if (insideWall(local, world) || outsidePen(local.x, local.y)) { | |
| 413 | local.x = was.x; | |
| 414 | local.y = was.y; | |
| 415 | if (insideWall(local, world)) anchor(local, snap.me); | |
| 416 | } | |
| 427 | // it; carry it that far forward and lean the drawn body onto it. Walking | |
| 428 | // out of the spawn pen, or into a barrier, is refused by the server | |
| 429 | // outright, so it is refused here too — see predict.js for how a refusal | |
| 430 | // holds rather than snaps. | |
| 431 | stepLocal(local, snap.me, held, speed, (now - snapAt) / 1000, dt, world, refused); | |
| 417 | 432 | drawnMe.id = snap.me.id; |
| 418 | 433 | drawnMe.x = local.x; |
| 419 | 434 | drawnMe.y = local.y; |
| @@ -499,17 +514,25 @@ function togglePerf() { | ||
| 499 | 514 | /** |
| 500 | 515 | * The readout, rewritten twice a second: the frame rate and the mean frame |
| 501 | 516 | * the governor decided on, the rate snapshots are actually arriving at, the |
| 502 | * interpolation delay in force, and how wide the world is being drawn. | |
| 517 | * interpolation delay in force, how wide the world is being drawn, and how | |
| 518 | * many times a minute the drawing took the server's word outright — your | |
| 519 | * own body anchored, somebody else started again, the view re-pointed. Each | |
| 520 | * of those is a jump, and a jump is what a stutter is made of. | |
| 503 | 521 | */ |
| 522 | let perfSince = 0; | |
| 504 | 523 | function paintPerf(nowMs, force) { |
| 505 | 524 | const box = $("perf"); |
| 506 | 525 | if (!perfOn || !box || (!force && nowMs - perfAt < 500)) return; |
| 507 | 526 | perfAt = nowMs; |
| 527 | if (perfSince === 0) perfSince = nowMs; | |
| 508 | 528 | const hz = timeline.interval > 0 ? 1 / timeline.interval : 0; |
| 529 | const jumps = local.anchored + timeline.restarts + head.corrected; | |
| 530 | const minutes = Math.max(1 / 60, (nowMs - perfSince) / 60000); | |
| 509 | 531 | box.textContent = |
| 510 | 532 | `${budget.fps.toFixed(1)} fps · ${budget.mean.toFixed(1)} ms/frame · ` + |
| 511 | 533 | `${hz.toFixed(1)} Hz in · ${(delay(timeline) * 1000).toFixed(0)} ms behind · ` + |
| 512 | `${render.width}×${render.height} of ${view.width}×${view.height}`; | |
| 534 | `${render.width}×${render.height} of ${view.width}×${view.height} · ` + | |
| 535 | `${jumps} jumps (${(jumps / minutes).toFixed(1)}/min)`; | |
| 513 | 536 | } |
| 514 | 537 | |
| 515 | 538 | /** |
static/yaw.js +3 −1
| @@ -24,7 +24,8 @@ export const SENT_MEMORY = 1.2; | ||
| 24 | 24 | /** How close an echo must be to count as ours. The server rounds to 0.1. */ |
| 25 | 25 | export const SENT_TOLERANCE = 0.08; |
| 26 | 26 | |
| 27 | export const makeYaw = (angle = 0) => ({ angle, id: "", alive: false, ready: false, sent: [] }); | |
| 27 | /** `corrected` counts the times the server's angle replaced ours: every one is a jump of the view. */ | |
| 28 | export const makeYaw = (angle = 0) => ({ angle, id: "", alive: false, ready: false, sent: [], corrected: 0 }); | |
| 28 | 29 | |
| 29 | 30 | /** The short way round from one angle to another, in (-π, π]. */ |
| 30 | 31 | export const shortWay = (from, to) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI; |
| @@ -65,6 +66,7 @@ export function settle(yaw, me, at) { | ||
| 65 | 66 | const respawned = me.alive && !yaw.alive; |
| 66 | 67 | const corrected = yaw.sent.length > 0 && !ours(yaw, me.angle, at); |
| 67 | 68 | if (seatChanged || respawned || corrected) { |
| 69 | if (yaw.ready) yaw.corrected += 1; | |
| 68 | 70 | yaw.angle = me.angle; |
| 69 | 71 | yaw.sent.length = 0; |
| 70 | 72 | } |