pike/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Send the movement speed instead of letting the client re-derive it
The browser rebuilt the server's speed decision from four snapshot fields — haste, slow, holding still to plant or defuse, the weight of an operator, the phases where nobody moves — which was `speedOf` in src/match.ts written twice. The copy is correct only until somebody adds a fifth reason, and then the drawn body creeps away from the server's answer and is pulled back. So the snapshot carries `me.speed`: the match resolves every reason it has to speed a body up, slow it down or hold it still, and the client passes the number straight into stepLocal. A rule that stops you arrives as a zero and the guess stops with it, with no client-side knowledge of why. `speedOf` is now the one place a fifth reason gets added, and it takes the state, so the phases whose tick returns before it moves anybody (end, over) and the dead are inside the rule rather than copied into the browser. The speed goes to a tenth rather than a whole unit: measured over three minutes of a jittery wire, a whole number draws a hasted walk half a unit a second fast and the cap clips it back every frame — 1.97% per-frame spread against 1.74% for the exact rule. A tenth matches the exact rule to two decimals and costs at most two bytes. The three fields that existed only to be re-derived — slow, haste, defusing — are off the snapshot; nothing else read them. Net, a seat's snapshot is about 23 bytes a tick smaller. Co-Authored-By: Claude Opus 5 <[email protected]>
8 files
changed +230
−30
README.md +5 −1
| @@ -65,7 +65,11 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 65 | 65 | - Your own body is *drawn* from a guess — the browser runs your own movement |
| 66 | 66 | forward from your own keys between snapshots, through the same |
| 67 | 67 | step-and-slide the server uses (`src/engine/glide.ts`), and carries the last |
| 68 | snapshot forward by up to a quarter second of held input. It is a drawing | |
| 68 | snapshot forward by up to a quarter second of held input. How fast it runs | |
| 69 | is the server's answer and not one the browser worked out: the snapshot | |
| 70 | carries `me.speed`, so a haste, an operator, a plant or a decided round | |
| 71 | reaches the drawing as a number — a rule that stops you arrives as a zero. | |
| 72 | It is a drawing | |
| 69 | 73 | optimisation only: nothing is sent, every snapshot leans the drawn body |
| 70 | 74 | back onto the server's answer, and a real disagreement is taken outright. |
| 71 | 75 | The lag your own keys take to reach the server (a send every 50ms, a hop, |
src/arena.test.ts +58
| @@ -64,3 +64,61 @@ test("a snapshot stays small enough to send thirty times a second", () => { | ||
| 64 | 64 | expect(bytes).toBeGreaterThan(200); |
| 65 | 65 | expect(bytes).toBeLessThan(8000); |
| 66 | 66 | }); |
| 67 | ||
| 68 | test("the snapshot carries the speed the server will move you at, not the reasons for it", () => { | |
| 69 | // The browser used to rebuild this rule from four fields and was right | |
| 70 | // only until a fifth was added. What it gets now is the answer: every | |
| 71 | // reason the match has to speed a body up, slow it down or hold it still, | |
| 72 | // resolved here, in units a second, to a tenth like every other number in | |
| 73 | // the snapshot. | |
| 74 | for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1); | |
| 75 | const state = match(); | |
| 76 | const me = state.players[0]!; | |
| 77 | const was = { ...me }; | |
| 78 | const phase = state.phase; | |
| 79 | const seen = () => JSON.parse(snapshot(me.id)).me as Record<string, unknown>; | |
| 80 | const speed = () => seen().speed; | |
| 81 | ||
| 82 | Object.assign(me, { alive: true, haste: 0, slow: 0, planting: 0, defusing: 0, weapon: "classic" }); | |
| 83 | expect(speed()).toBe(250); | |
| 84 | me.weapon = "operator"; | |
| 85 | expect(speed()).toBe(215); | |
| 86 | me.weapon = "classic"; | |
| 87 | me.haste = 2; | |
| 88 | expect(speed()).toBe(337.5); // exactly what the tick will use | |
| 89 | me.haste = 0; | |
| 90 | me.slow = 2; | |
| 91 | expect(speed()).toBe(137.5); | |
| 92 | me.slow = 0; | |
| 93 | ||
| 94 | // Every way the match holds a body still arrives as a zero, and the client | |
| 95 | // is told none of them: a fifth one would need no change over there. | |
| 96 | me.planting = 1; | |
| 97 | expect(speed()).toBe(0); | |
| 98 | me.planting = 0; | |
| 99 | me.defusing = 1; | |
| 100 | expect(speed()).toBe(0); | |
| 101 | me.defusing = 0; | |
| 102 | me.alive = false; | |
| 103 | expect(speed()).toBe(0); | |
| 104 | me.alive = true; | |
| 105 | // Including the phases whose tick returns before it moves anybody. | |
| 106 | for (const stopped of ["end", "over"] as const) { | |
| 107 | state.phase = stopped; | |
| 108 | expect(speed()).toBe(0); | |
| 109 | } | |
| 110 | state.phase = phase; | |
| 111 | expect(speed()).toBe(250); | |
| 112 | ||
| 113 | // The three fields that existed only to be re-derived are gone with it. | |
| 114 | const me2 = seen(); | |
| 115 | expect(me2).not.toHaveProperty("haste"); | |
| 116 | expect(me2).not.toHaveProperty("slow"); | |
| 117 | expect(me2).not.toHaveProperty("defusing"); | |
| 118 | // What it costs the wire: a name and a number, once a seat a tick. | |
| 119 | const field = `"speed":${speed()},`; | |
| 120 | expect(snapshot(me.id)).toContain(field); | |
| 121 | expect(field.length).toBeLessThanOrEqual(16); | |
| 122 | ||
| 123 | Object.assign(me, was); | |
| 124 | }); |
src/arena.ts +10 −6
| @@ -17,7 +17,7 @@ import { recordResult } from "./db.ts"; | ||
| 17 | 17 | import { makeArena, type Command, type Why } from "./engine/arena.ts"; |
| 18 | 18 | import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts"; |
| 19 | 19 | import type { Point } from "./map.ts"; |
| 20 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, tick, visible } from "./match.ts"; | |
| 20 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 21 | 21 | import type { MatchState, Player } from "./state.ts"; |
| 22 | 22 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 23 | 23 | |
| @@ -142,13 +142,17 @@ function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | ||
| 142 | 142 | ult: me.ult, |
| 143 | 143 | ultNeeds: agent.abilities.x.points, |
| 144 | 144 | blind: round2(me.blind), |
| 145 | slow: round2(me.slow), | |
| 146 | haste: round2(me.haste), | |
| 147 | 145 | spike: me.spike, |
| 148 | 146 | planting: round2(me.planting), |
| 149 | // Your own two ways of being held still, so the browser's prediction | |
| 150 | // of your own body runs at the speed the server is about to use. | |
| 151 | defusing: me.defusing > 0, | |
| 147 | // How fast the server is about to move you, in units a second, and not | |
| 148 | // the reasons behind it: the browser's prediction runs at this number | |
| 149 | // and re-derives nothing. A rule that holds you still — planting, | |
| 150 | // defusing, a decided round — arrives as a zero. To a tenth, like every | |
| 151 | // other number here: rounded to a whole unit instead, a hasted walk | |
| 152 | // (337.5) is drawn a half-unit a second fast and clipped back by the | |
| 153 | // cap every frame, which measured 0.2 points of per-frame spread. A | |
| 154 | // tenth is a no-op for feel and costs at most two bytes. | |
| 155 | speed: round2(speedOf(state, me)), | |
| 152 | 156 | bot: me.bot, |
| 153 | 157 | }, |
| 154 | 158 | players: seen.map((p) => ({ |
src/client.test.ts +86
| @@ -525,3 +525,89 @@ test("the buy phase is predicted, and its pen is the server's own rule", async ( | ||
| 525 | 525 | expect(bundle).toContain("world, refused)"); |
| 526 | 526 | expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)"); |
| 527 | 527 | }); |
| 528 | ||
| 529 | test("the drawn body runs at the speed the server sent, and never at one it works out", async () => { | |
| 530 | // Two snapshots a client that re-derived the rule would read backwards: one | |
| 531 | // that says stopped with every visible reason to be running, and one that | |
| 532 | // says running with a visible reason to be stopped. The drawing follows the | |
| 533 | // number both times, which is the whole of what it knows about movement. | |
| 534 | const arena = await import("./arena.ts"); | |
| 535 | const { blockedCircle } = await import("./map.ts"); | |
| 536 | arena.stopArena(); | |
| 537 | const body = arena.match().players[0]!; | |
| 538 | const base = JSON.parse(arena.snapshot(body.id)); | |
| 539 | ||
| 540 | // Somewhere with a hundred units of room in one direction to walk into. | |
| 541 | const clear = ( | |
| 542 | [ | |
| 543 | ["KeyW", 0, -1], | |
| 544 | ["KeyS", 0, 1], | |
| 545 | ["KeyA", -1, 0], | |
| 546 | ["KeyD", 1, 0], | |
| 547 | ] as const | |
| 548 | ).find(([, dx, dy]) => [25, 50, 75, 100].every((step) => !blockedCircle(body.x + dx * step, body.y + dy * step, 16)))!; | |
| 549 | expect(clear).toBeDefined(); | |
| 550 | ||
| 551 | // The top-down view, where your own body is the last thing translated to. | |
| 552 | if ((nodes.get("stage")!.dataset as Record<string, string>).mode !== "top") press("KeyV"); | |
| 553 | expect((nodes.get("stage")!.dataset as Record<string, string>).mode).toBe("top"); | |
| 554 | for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code); | |
| 555 | press(clear[0]); | |
| 556 | ||
| 557 | const mine = (base.players as Record<string, unknown>[]).find((p) => p.id === base.me.id) ?? { | |
| 558 | id: base.me.id, | |
| 559 | name: body.name, | |
| 560 | agent: base.me.agent, | |
| 561 | angle: 0, | |
| 562 | hp: 100, | |
| 563 | armour: 0, | |
| 564 | mate: true, | |
| 565 | spike: false, | |
| 566 | blind: false, | |
| 567 | revealed: false, | |
| 568 | }; | |
| 569 | const scale = 900 / (30 * 80); // canvas pixels to a world unit, as above | |
| 570 | const deliver = (me: Record<string, unknown>) => { | |
| 571 | const snap = { | |
| 572 | ...base, | |
| 573 | phase: "live", | |
| 574 | zones: [], | |
| 575 | // Your own body is on the map as one of the bodies you can see, and it | |
| 576 | // is the one the arrow is drawn for. | |
| 577 | players: [{ ...mine, alive: true, x: body.x, y: body.y }], | |
| 578 | shots: [], | |
| 579 | me: { ...base.me, alive: true, x: body.x, y: body.y, planting: 0, ...me }, | |
| 580 | }; | |
| 581 | openedSocket!.onmessage!({ data: JSON.stringify(snap) } as MessageEvent); | |
| 582 | }; | |
| 583 | /** How far past the server's own word the body is drawn, after ten frames. */ | |
| 584 | const leadOf = (me: Record<string, unknown>) => { | |
| 585 | deliver(me); | |
| 586 | const t0 = performance.now(); | |
| 587 | runFrame(t0); // the first frame after a jump takes the server's word | |
| 588 | for (let i = 1; i <= 10; i++) { | |
| 589 | moves.length = 0; | |
| 590 | runFrame(t0 + i * 40); | |
| 591 | } | |
| 592 | const at = moves[moves.length - 1]!; | |
| 593 | return (at.x / scale - body.x) * clear[1] + (at.y / scale - body.y) * clear[2]; | |
| 594 | }; | |
| 595 | ||
| 596 | // Stopped, with nothing in the snapshot to say why: no planting, no | |
| 597 | // slow, a live round, a light weapon. A client working it out for itself | |
| 598 | // would run this body the whole quarter second. | |
| 599 | expect(leadOf({ speed: 0 })).toBeLessThan(1); | |
| 600 | // And moving, mid-plant — a reason the old client read as stopped. The | |
| 601 | // number is what counts, so the body walks. | |
| 602 | const walking = leadOf({ speed: 250, planting: 1 }); | |
| 603 | expect(walking).toBeGreaterThan(30); | |
| 604 | // And the number is the whole of it: half the speed, half the lead. The | |
| 605 | // cap on a guess is a length of time (predict.js LEAD_MAX), so how far | |
| 606 | // ahead the drawing may be is the server's speed and nothing else. | |
| 607 | const half = leadOf({ speed: 125 }); | |
| 608 | expect(half / walking).toBeGreaterThan(0.45); | |
| 609 | expect(half / walking).toBeLessThan(0.55); | |
| 610 | ||
| 611 | release(clear[0]); | |
| 612 | openedSocket?.close(); | |
| 613 | }); |
src/engine/glide.ts +4 −3
| @@ -112,9 +112,10 @@ export function slideCircle( | ||
| 112 | 112 | } |
| 113 | 113 | |
| 114 | 114 | /** |
| 115 | * How fast a body moves, given what is on it. The browser reads the same | |
| 116 | * four facts out of its snapshot that the match reads off a player, so a | |
| 117 | * prediction runs at the speed the server is about to use. | |
| 115 | * How fast a body moves, given what is on it. This is the server's alone: | |
| 116 | * the game decides here (`speedOf` in the match), and the snapshot hands the | |
| 117 | * browser the resulting number. Nothing in a client reads these four facts, | |
| 118 | * so a fifth one can be added here without desyncing anybody's prediction. | |
| 118 | 119 | */ |
| 119 | 120 | export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number { |
| 120 | 121 | if (state.stopped) return 0; |
src/feel.test.ts +41 −6
| @@ -11,9 +11,9 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { BODY_R, moveSpeed, RUN } from "./engine/glide.ts"; | |
| 14 | import { BODY_R, RUN } from "./engine/glide.ts"; | |
| 15 | 15 | import { solidTile, TILE } from "./map.ts"; |
| 16 | import { newMatch, setInput, startRound, tick } from "./match.ts"; | |
| 16 | import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts"; | |
| 17 | 17 | import { runBots } from "./bots.ts"; |
| 18 | 18 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js"; |
| 19 | 19 | import { makeLocal, stepLocal } from "../static/engine/predict.js"; |
| @@ -52,14 +52,19 @@ const spreadOf = (steps: number[]) => { | ||
| 52 | 52 | }; |
| 53 | 53 | |
| 54 | 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[] }; | |
| 55 | type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string; speed: number }; bodies: Body[] }; | |
| 56 | 56 | |
| 57 | 57 | /** |
| 58 | 58 | * A person walking east across open ground for two seconds, sending intent |
| 59 | 59 | * every 50ms, hearing back thirty times a second over a jittery wire, and |
| 60 | 60 | * drawn at sixty frames — with the input reaching the server one way late. |
| 61 | * | |
| 62 | * The packet carries `speed` the way the arena's snapshot does — the match's | |
| 63 | * own answer, to a tenth — and the drawing uses that and nothing else, which | |
| 64 | * is what val.js does. `on` is whatever the walk is carrying: a haste, an | |
| 65 | * operator, the states whose speed is not the plain run. | |
| 61 | 66 | */ |
| 62 | function walkOwnBody(base = 0.02) { | |
| 67 | function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) { | |
| 63 | 68 | const random = rng(7); |
| 64 | 69 | const state = newMatch(11); |
| 65 | 70 | const me = state.players[0]!; |
| @@ -76,6 +81,7 @@ function walkOwnBody(base = 0.02) { | ||
| 76 | 81 | me.haste = 0; |
| 77 | 82 | me.slow = 0; |
| 78 | 83 | me.weapon = "classic"; |
| 84 | Object.assign(me, on); | |
| 79 | 85 | const packets: Packet[] = []; |
| 80 | 86 | const inputs: { at: number; mx: number; my: number }[] = []; |
| 81 | 87 | const held = { mx: 0, my: 0 }; |
| @@ -101,7 +107,15 @@ function walkOwnBody(base = 0.02) { | ||
| 101 | 107 | packets.push({ |
| 102 | 108 | at: t + base + jitter(random), |
| 103 | 109 | 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 }, | |
| 110 | me: { | |
| 111 | id: me.id, | |
| 112 | x: round2(me.x), | |
| 113 | y: round2(me.y), | |
| 114 | angle: round2(me.angle), | |
| 115 | alive: me.alive, | |
| 116 | phase: state.phase, | |
| 117 | speed: round2(speedOf(state, me)), | |
| 118 | }, | |
| 105 | 119 | bodies: [], |
| 106 | 120 | }); |
| 107 | 121 | } |
| @@ -125,7 +139,9 @@ function walkOwnBody(base = 0.02) { | ||
| 125 | 139 | if (!snap) continue; |
| 126 | 140 | held.mx = now >= walkFrom && now < walkUntil ? 1 : 0; |
| 127 | 141 | const before = local.anchored; |
| 128 | stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null); | |
| 142 | // The speed the server sent, exactly as val.js passes it: no rule about | |
| 143 | // movement is re-derived on this side of the wire. | |
| 144 | stepLocal(local, snap.me, held, snap.me.speed, now - snap.stamp, 1 / 60, world, null); | |
| 129 | 145 | // The first word about where you are is taken outright: that one is not a jump. |
| 130 | 146 | if (before > 0) anchors += local.anchored - before; |
| 131 | 147 | if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) { |
| @@ -155,6 +171,25 @@ test("your own body, walking, takes even steps and never a backward one", () => | ||
| 155 | 171 | expect(anchors).toBe(0); |
| 156 | 172 | }); |
| 157 | 173 | |
| 174 | test("a hasted walk is drawn at the speed the wire carried, rounding and all", () => { | |
| 175 | // Haste is the case where the match's own answer is not a whole number: | |
| 176 | // 250 × 1.35 is 337.5, and the wire carries it to a tenth, like every other | |
| 177 | // number in the snapshot. What is checked is that the drawing runs at the | |
| 178 | // speed the server will use — no correction, no backward step, and no body | |
| 179 | // drawn out past where the server is about to put it. | |
| 180 | const { steps, back, anchors, local, server } = walkOwnBody(0.02, { haste: 10 }); | |
| 181 | const { mean, spread } = spreadOf(steps); | |
| 182 | console.log( | |
| 183 | `hasted body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + | |
| 184 | `largest backward step ${back.toFixed(2)}, anchors ${anchors}, drawn ${(local.x - server.x).toFixed(1)} past the server`, | |
| 185 | ); | |
| 186 | expect(mean * 60).toBeGreaterThan(RUN * 1.35 * 0.9); | |
| 187 | expect(mean * 60).toBeLessThan(RUN * 1.35 * 1.1); | |
| 188 | expect(spread).toBeLessThan(0.05); | |
| 189 | expect(back).toBeGreaterThan(-0.01); | |
| 190 | expect(anchors).toBe(0); | |
| 191 | }); | |
| 192 | ||
| 158 | 193 | test("starting and stopping never pulls you back, and the speed never dips a third", () => { |
| 159 | 194 | // The first tenth of a second after the key goes down the server has not |
| 160 | 195 | // heard yet, and the guess used to be yanked back to the server's still |
src/match.ts +18 −4
| @@ -26,8 +26,10 @@ import { | ||
| 26 | 26 | import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts"; |
| 27 | 27 | import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts"; |
| 28 | 28 | |
| 29 | // A body's size and its speed are shared with the browser, which predicts | |
| 30 | // your own movement between snapshots: `src/engine/glide.ts` is the one copy. | |
| 29 | // A body's size and the step-and-slide are shared with the browser, which | |
| 30 | // predicts your own movement between snapshots: `src/engine/glide.ts` is the | |
| 31 | // one copy. How fast that body moves is decided here alone, by `speedOf`, | |
| 32 | // and sent as a number — see the note on it. | |
| 31 | 33 | export const PLAYER_R = BODY_R; |
| 32 | 34 | export const RUN_SPEED = RUN; |
| 33 | 35 | export const BUY_TIME = 14; |
| @@ -359,7 +361,19 @@ export function tick(state: MatchState, dt: number): void { | ||
| 359 | 361 | endConditions(state); |
| 360 | 362 | } |
| 361 | 363 | |
| 362 | function speedOf(player: Player): number { | |
| 364 | /** | |
| 365 | * How fast this player moves right now — every reason the match has to speed | |
| 366 | * a body up, slow it down or hold it still, in one number. | |
| 367 | * | |
| 368 | * The browser used to rebuild this from four snapshot fields, which was this | |
| 369 | * function written twice and correct only until a fifth reason was added. | |
| 370 | * The snapshot carries the answer instead (`me.speed` in `src/arena.ts`), so | |
| 371 | * a new rule that stops you arrives as a zero and the prediction stops with | |
| 372 | * it without knowing why. Add a reason here and nothing else needs telling. | |
| 373 | */ | |
| 374 | export function speedOf(state: MatchState, player: Player): number { | |
| 375 | // The phases whose tick returns before it moves anybody, and the dead. | |
| 376 | if (state.phase === "end" || state.phase === "over" || !player.alive) return 0; | |
| 363 | 377 | return moveSpeed({ |
| 364 | 378 | haste: player.haste > 0, |
| 365 | 379 | slow: player.slow > 0, |
| @@ -379,7 +393,7 @@ function movePlayer(state: MatchState, player: Player, dt: number, penned: boole | ||
| 379 | 393 | mx /= len; |
| 380 | 394 | my /= len; |
| 381 | 395 | } |
| 382 | const speed = speedOf(player) * dt; | |
| 396 | const speed = speedOf(state, player) * dt; | |
| 383 | 397 | if (speed === 0 || (mx === 0 && my === 0)) return; |
| 384 | 398 | let next = moveCircle(player.x, player.y, mx * speed, my * speed, PLAYER_R); |
| 385 | 399 | // In the buy phase nobody leaves their own spawn. |
static/val.js +8 −10
| @@ -15,7 +15,7 @@ import { banner, drawBanner } from "./banner.js"; | ||
| 15 | 15 | import { makeBeeper } from "./beep.js"; |
| 16 | 16 | import { makeBudget, measure } from "./engine/budget.js"; |
| 17 | 17 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "./engine/interp.js"; |
| 18 | import { BODY_R, moveSpeed } from "../src/engine/glide.ts"; | |
| 18 | import { BODY_R } from "../src/engine/glide.ts"; | |
| 19 | 19 | import { drawGun, gunFire, gunSnapshot, gunTick, makeGun } from "./engine/gun.js"; |
| 20 | 20 | import { makeLocal, stepLocal } from "./engine/predict.js"; |
| 21 | 21 | import { forget, ghosts, makePresence, shown, sighted, slide } from "./engine/presence.js"; |
| @@ -418,15 +418,13 @@ function predict(dt, now) { | ||
| 418 | 418 | return; |
| 419 | 419 | } |
| 420 | 420 | const held = readIntent(); |
| 421 | const speed = moveSpeed({ | |
| 422 | haste: snap.me.haste > 0, | |
| 423 | slow: snap.me.slow > 0, | |
| 424 | // Nobody moves once a round is decided: the server's tick returns before | |
| 425 | // it moves anybody, and a guess that kept walking ran a quarter second | |
| 426 | // into nothing and waited there for the next round to cut back. | |
| 427 | stopped: snap.me.planting > 0 || snap.me.defusing || snap.phase === "end" || snap.phase === "over", | |
| 428 | heavy: snap.me.weapon === "operator", | |
| 429 | }); | |
| 421 | // The speed the server is about to move you at, as it sent it. This was | |
| 422 | // rebuilt here out of four snapshot fields — haste, slow, holding still to | |
| 423 | // plant or defuse, the weight of an operator, the phases where nobody | |
| 424 | // moves — which was the server's rule written twice and right only until | |
| 425 | // somebody added a fifth. Now a rule that stops you arrives as a zero and | |
| 426 | // the guess stops with it, with no knowledge here of why. | |
| 427 | const speed = snap.me.speed; | |
| 430 | 428 | // The snapshot is a moment old and the keys held since have not reached |
| 431 | 429 | // it; carry it that far forward and lean the drawn body onto it. Walking |
| 432 | 430 | // out of the spawn pen, or into a barrier, is refused by the server |