quill/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
File snapshots on an even clock, hold the lag instead of yanking it, and count the jumps
The player still said it was not smooth after the last three fixes, so this starts by measuring instead of trusting the list. A headless wire between the real match and the real client modules (src/feel.test.ts) — thirty snapshots a second, arriving with the jitter that was measured on the box (p50 33ms, p99 41–48ms), drawn at sixty — puts numbers on what was left: own body walking step spread 17.0% -> 0.0% walk start yanked back 4.9 -> 0.0 backward, never below full speed everybody else step spread 17.3% -> 7.3%, restarts 0 render clock 11.9–19.0ms/frame -> 16.3–17.0ms/frame round start camera swung east a tick after facing the site -> faces the site Three causes, none of them throughput. The wire's rhythm was being drawn. Samples were filed under the time they landed, so a snapshot a few milliseconds late and the next a few early made a body walk slow then fast between them, and made your own snapshot's age — the lead your keys are carried by — jump at every arrival. interp.js now files arrivals on an even clock: one interval after the last, pulled a tenth of the way toward when each really came, trimmed if they keep landing on one side, never ahead of an arrival by more than half a beat (a burst from a background tab must not walk it into the future), never backwards. The moment being drawn advances at real time and creeps toward its delay at 2% of it, so the delay adapting is not a short frame. The delay itself sat at 45ms and was wobbling to 61; it now holds 45.0–45.2. The lag was being called a disagreement. Your keys take a send interval, a hop and a tick to show in the server's answer; the guess ran ahead by exactly that and was pulled back onto the answer — a hard clamp at every snapshot, so every start of movement was a yank backwards and the steady walk beat with the wire. predict.js now lets the drawn body lead the projected answer by LEAD_SLACK (80ms of running) along the way it is going, leaves that part alone, leans across-track and behind-the-server gaps at the old rate, and after a stop holds for the lag and then leans off gently. Barriers an ability put up are refused the way the map's walls are, so the guess cannot stand inside one; nobody moves in the end and over phases, so the guess does not either. The frame's composition moved from val.js into one stepLocal so the harness can drive it. The server stomped its own spawn facing. startRound pointed everybody at the site and then reset their input to an empty one whose angle is zero, and the next tick copied that onto the player: two camera jumps in the first hundred milliseconds of every round. The input now keeps the facing the spawn gave. Also: the gun kicks the moment the trigger is pulled, and the snapshot's word about that shot is not a second kick (the server still fires and lands it); the ` readout prints the count of corrections taken outright — anchors, restarts, re-pointings — and the rate a minute, so a stutter has a number. Two tests were changed because the rule they encoded changed: the client test's bound on how far the drawn body may lead a stopped server now includes LEAD_SLACK, and its text check on where a refused step is held follows the code into stepLocal. Co-Authored-By: Claude Fable 5.1 <[email protected]>
10 files
changed +679
−71
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 +12 −8
| @@ -363,9 +363,11 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 363 | 363 | // in time (predict.js LEAD_MAX), so the bound has to be read off the same |
| 364 | 364 | // speed table the client used, or it measures the match's mood instead of |
| 365 | 365 | // the rule. |
| 366 | // The lead is the cap plus the slack the guess is allowed over the answer | |
| 367 | // (predict.js LEAD_SLACK: the lag your own keys take to reach the server). | |
| 366 | 368 | const { moveSpeed } = await import("./glide.ts"); |
| 367 | const { LEAD_MAX } = await import("../static/predict.js"); | |
| 368 | expect(lead).toBeLessThan(LEAD_MAX * moveSpeed(seat) + 10); | |
| 369 | const { LEAD_MAX, LEAD_SLACK } = await import("../static/predict.js"); | |
| 370 | expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10); | |
| 369 | 371 | expect(Math.abs(sideways)).toBeLessThan(6); |
| 370 | 372 | // Nothing on the server moved: this is a drawing, and the match is still |
| 371 | 373 | // the only thing that says where anybody is. |
| @@ -410,6 +412,9 @@ test("the bundled client connects, paints the HUD and draws a frame", async () = | ||
| 410 | 412 | expect(readout).toContain("Hz in"); |
| 411 | 413 | expect(readout).toContain("ms behind"); |
| 412 | 414 | expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas |
| 415 | // And the count of corrections taken outright, per minute: a stutter is a | |
| 416 | // jump, and this is how many there have been. | |
| 417 | expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/); | |
| 413 | 418 | press("Backquote"); |
| 414 | 419 | expect((perf.style as Record<string, string>).display).toBe("none"); |
| 415 | 420 | |
| @@ -479,10 +484,9 @@ test("the buy phase is predicted, and its pen is the server's own rule", async ( | ||
| 479 | 484 | expect(bundle).toContain('snap.phase !== "buy" || pens !== null'); |
| 480 | 485 | // And the pen is the server's number, not a guess: 120 from a spawn tile. |
| 481 | 486 | 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 | |
| 487 | // A refused step holds where it was rather than snapping to the server: | |
| 488 | // the pen is handed to the one step function as the place it is refused. | |
| 489 | expect(bundle).toContain("stepLocal(local, snap.me, held, speed"); | |
| 490 | expect(bundle).toContain("world, refused)"); | |
| 491 | expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)"); | |
| 488 | 492 | }); |
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 +84 −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,33 @@ 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 | if (insideWall(local, world)) anchor(local, me); | |
| 185 | } | |
| 186 | return local; | |
| 187 | } |
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 | } |