Public: anyone can read this; the crew can change it.
forked from trevato/cs
Your own body stops waiting for the network: your movement is predicted and reconciled against the server's word #383
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/316-your-own-body-stops-waiting-for-the-netw → main 3 commits fork
13 files
changed +852
−49
README.md +19 −6
| @@ -1718,12 +1718,25 @@ What is real and what is not, today: | ||
| 1718 | 1718 | economy (the buy goes up with the input frame and is spent on the server), and |
| 1719 | 1719 | the caps — twenty rooms a process, ten people a room, an empty room forgotten |
| 1720 | 1720 | after ninety seconds (`src/rooms/hub.ts`). |
| 1721 | - Not yet: your own movement is **not** predicted, so it answers on the round | |
| 1722 | trip rather than instantly; a room has no hit marker or damage arrow (both are | |
| 1723 | effects recorded for one screen and a snapshot goes to everybody). Who is in a | |
| 1724 | room is seen from inside it: there is no page that lists a room's people before | |
| 1725 | you join, because a code is all a stranger would need to read it. Rooms live | |
| 1726 | in this process only — a deploy ends them. | |
| 1721 | - Real, and predicted: **your own movement.** Every frame of keys that goes up | |
| 1722 | is also kept, and your body is moved by it at once through the engine's own | |
| 1723 | mover on the game's own tuning and hull (`src/client/predict.ts`); each | |
| 1724 | snapshot ends with the stamp of the last input the server applied to your | |
| 1725 | seat, and your body is put back on the server's word with every newer input | |
| 1726 | replayed on top. A correction under 32 units (a hull's width) is eased out | |
| 1727 | over 100 ms, a bigger one snaps. A test steps a real room on a quarter-second | |
| 1728 | round trip and holds the page's body to the server's within a hundredth of a | |
| 1729 | unit at every tick; the replay costs about 0.06 ms a snapshot. | |
| 1730 | - Still the server's word, and never predicted: shots, hits, damage, armour, | |
| 1731 | ammunition, the weapon in your hands, money, the score and the round. Nobody | |
| 1732 | else's body is predicted either — the other nine are drawn where the last | |
| 1733 | snapshot put them, in 62 ms steps. Prediction is off while you are dead. | |
| 1734 | - Not yet: other bodies are not interpolated between snapshots; a room has no | |
| 1735 | hit marker or damage arrow (both are effects recorded for one screen and a | |
| 1736 | snapshot goes to everybody). Who is in a room is seen from inside it: there | |
| 1737 | is no page that lists a room's people before you join, because a code is all | |
| 1738 | a stranger would need to read it. Rooms live in this process only — a deploy | |
| 1739 | ends them. | |
| 1727 | 1740 | |
| 1728 | 1741 | ### Whose result a room's match is |
| 1729 | 1742 |
src/budget.test.ts +47 −1
| @@ -31,6 +31,10 @@ | ||
| 31 | 31 | * - `buildRangeScene` on a live run: the room, the plates standing in it and a |
| 32 | 32 | * tracer still fading. The range's frame is a thirtieth of the shooter's and |
| 33 | 33 | * is measured anyway, because a game with no ceiling is where the cost goes. |
| 34 | * - A snapshot reconciled in a room: your own body put back on the server's | |
| 35 | * word and twenty ticks of your unanswered inputs replayed through the mover | |
| 36 | * — a 250 ms round trip and a snapshot's wait — which a member's page does | |
| 37 | * sixteen times a second on top of its frame. | |
| 34 | 38 | * - And a frame deliberately made dearer, which must fail the same ceiling: a |
| 35 | 39 | * guard nothing can trip is not a guard, and the last test here proves this |
| 36 | 40 | * one trips. |
| @@ -96,7 +100,7 @@ | ||
| 96 | 100 | */ |
| 97 | 101 | |
| 98 | 102 | import { expect, test } from "bun:test"; |
| 99 | import { pushEffect, resetEffects, type Camera } from "./engine/index.ts"; | |
| 103 | import { pushEffect, resetEffects, type Camera, type Vec3 } from "./engine/index.ts"; | |
| 100 | 104 | import { DE_FORGE } from "./client/map.ts"; |
| 101 | 105 | import { SHORT } from "./client/match.ts"; |
| 102 | 106 | import { buildScene, createScene } from "./client/scene.ts"; |
| @@ -105,6 +109,7 @@ import { NO_INPUT, createWorld, update, type Effect, type World } from "./client | ||
| 105 | 109 | import { buildRangeScene, createRangeScene } from "./range/scene.ts"; |
| 106 | 110 | import { EYE, createRun, start, step } from "./range/targets.ts"; |
| 107 | 111 | import { createRoom, stepRoom, type Room } from "./rooms/room.ts"; |
| 112 | import { createPrediction, keep, reconcile, TICK as PREDICT_TICK } from "./client/predict.ts"; | |
| 108 | 113 | |
| 109 | 114 | /** The fixed simulation step, and the wall clock a frame of it has to fit in. */ |
| 110 | 115 | const TICK = 1 / 64; |
| @@ -124,6 +129,16 @@ const RANGE_SCENE_CEILING = 0.02; | ||
| 124 | 129 | */ |
| 125 | 130 | const ROOM_SECOND_CEILING = 40; |
| 126 | 131 | |
| 132 | /** | |
| 133 | * A room member's own body replayed over a 250 ms round trip and the snapshot | |
| 134 | * interval on top of it — twenty ticks of the mover — which is what every | |
| 135 | * snapshot that lands costs a page sixteen times a second. Today: 0.063 ms, so | |
| 136 | * the ceiling is eight times that and still a thirtieth of a frame. | |
| 137 | */ | |
| 138 | const REPLAY_CEILING = 0.5; | |
| 139 | /** How many ticks that replay is: 250 ms of 64 Hz, plus one 62.5 ms snapshot wait. */ | |
| 140 | const REPLAY_TICKS = Math.round((250 + 62.5) / (1000 * PREDICT_TICK)); | |
| 141 | ||
| 127 | 142 | /** Batches thrown away before any are kept, so nothing measured is cold. */ |
| 128 | 143 | const WARM = 2; |
| 129 | 144 | /** Batches kept, of which the median is the answer. Odd, so there is one. */ |
| @@ -431,3 +446,34 @@ test("the range's scene is built in a fraction of what a tick costs", () => { | ||
| 431 | 446 | expect(scene.instanceCount).toBe(1 + run.targets.length); |
| 432 | 447 | expect(scene.lineCount).toBe(2); |
| 433 | 448 | }, PATIENCE); |
| 449 | ||
| 450 | /** | |
| 451 | * What prediction costs a page in a room: a snapshot lands, your body goes back | |
| 452 | * to the server's word, and every input it has not answered is played through | |
| 453 | * the mover again. Twenty of them is a 250 ms round trip with a snapshot's wait | |
| 454 | * on top, on de_forge's boxes, strafing and turning so the walls are met. It is | |
| 455 | * measured here so a slower mover, or a history that forgot its cap, cannot | |
| 456 | * quietly become the frame's problem. | |
| 457 | */ | |
| 458 | test("replaying a 250 ms round trip of your own inputs is a small slice of a frame", () => { | |
| 459 | const world = liveRound(7); | |
| 460 | const me = world.me; | |
| 461 | const own = { pos: [...me.pos] as Vec3, vel: [0, 0, 0] as Vec3 }; | |
| 462 | const runs = 200; | |
| 463 | let replayed = 0; | |
| 464 | const each = measure(`a snapshot reconciled: ${REPLAY_TICKS} ticks of your own inputs replayed`, REPLAY_CEILING, () => { | |
| 465 | const p = createPrediction(); | |
| 466 | reconcile(p, own, 0, world, me, 0); | |
| 467 | for (let t = 1; t <= REPLAY_TICKS; t++) | |
| 468 | keep(p, { tick: t, input: { ...NO_INPUT, forward: 1, side: t % 2 ? 1 : -1 }, yaw: t * 9, pitch: 0, switchCode: 0, buy: null }, 1, world, me); | |
| 469 | return timed(runs, () => { | |
| 470 | for (let i = 0; i < runs; i++) reconcile(p, own, 0, world, me, i); | |
| 471 | replayed = p.kept.length; | |
| 472 | }); | |
| 473 | }); | |
| 474 | ||
| 475 | expect(each).toBeLessThan(REPLAY_CEILING); | |
| 476 | // And it really was twenty ticks each time: nothing acknowledged, nothing dropped. | |
| 477 | expect(replayed).toBe(REPLAY_TICKS); | |
| 478 | expect(REPLAY_TICKS).toBe(20); | |
| 479 | }, PATIENCE); |
src/client/main.ts +12 −6
| @@ -84,7 +84,7 @@ import { matchFrom, playKey } from "../matchid.ts"; | ||
| 84 | 84 | import { pastedLink } from "../matchlink.ts"; |
| 85 | 85 | import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts"; |
| 86 | 86 | import type { Situation } from "../plan/situation.ts"; |
| 87 | import { DE_FORGE } from "./map.ts"; | |
| 87 | import { DE_FORGE, levelNamed } from "./map.ts"; | |
| 88 | 88 | import type { Level } from "./level.ts"; |
| 89 | 89 | import { FOV, fovFor, lookAt } from "./look.ts"; |
| 90 | 90 | import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts"; |
| @@ -1270,7 +1270,9 @@ export function boot(host: Host): Frame { | ||
| 1270 | 1270 | code, |
| 1271 | 1271 | now: () => win.performance.now(), |
| 1272 | 1272 | build: (hello) => { |
| 1273 | const level = hello.map === "de_forge" ? DE_FORGE : generateLevel(seedOf(hello.map) ?? 0); | |
| 1273 | // The server's own builder (`levelNamed`), so the walls this page | |
| 1274 | // predicts your body against are the walls the room steps it against. | |
| 1275 | const level = levelNamed(hello.map); | |
| 1274 | 1276 | if (level !== shown && renderer !== null) { |
| 1275 | 1277 | renderer.replace("level", meshFor("level", level)); |
| 1276 | 1278 | shown = level; |
| @@ -1631,14 +1633,18 @@ export function boot(host: Host): Frame { | ||
| 1631 | 1633 | // of spawn while you buy is the point. |
| 1632 | 1634 | if (session !== null) { |
| 1633 | 1635 | // A room's round is stepped on the server: what goes up is one frame of |
| 1634 | // keys for the tick the loop just counted — coalesced into one message | |
| 1635 | // however many ticks this frame was worth, because a tick the page never | |
| 1636 | // simulated has no separate keys to send. The buy is asked for once. | |
| 1636 | // keys for the ticks the loop just counted — coalesced into one message | |
| 1637 | // however many ticks this frame was worth, since they are the same keys | |
| 1638 | // — and your body is moved by all of them here at once (`predict.ts`). | |
| 1639 | // The buy is asked for once. | |
| 1637 | 1640 | const held = buyPanel.hidden ? input : { ...input, fire: false, switchTo: null }; |
| 1638 | 1641 | if (steps > 0) { |
| 1639 | session.press({ input: held, yaw: me.yaw, pitch: me.pitch, switchCode, buy: roomBuy }); | |
| 1642 | session.press({ input: held, yaw: me.yaw, pitch: me.pitch, switchCode, buy: roomBuy }, steps); | |
| 1640 | 1643 | roomBuy = null; |
| 1641 | 1644 | } |
| 1645 | // Your predicted body, with what is left of the last correction eased in, | |
| 1646 | // before the camera reads where you are. | |
| 1647 | session.place(now); | |
| 1642 | 1648 | } else { |
| 1643 | 1649 | for (let step = 0; step < steps; step++) |
| 1644 | 1650 | update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false, switchTo: null }); |
src/client/map.ts +11
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | import * as g from "../engine/index.ts"; |
| 25 | 25 | import { CELL, levelOf, type Cell } from "./cells.ts"; |
| 26 | 26 | import type { Level } from "./level.ts"; |
| 27 | import { generateLevel, seedOf } from "./generate.ts"; | |
| 27 | 28 | |
| 28 | 29 | /** De_forge is 44 cells on a side: 5,632 Source units, a little under dust's. */ |
| 29 | 30 | export const GRID_W = 44; |
| @@ -81,3 +82,13 @@ const grid: g.Grid<Cell> = build(); | ||
| 81 | 82 | * page they are written on and nothing else. |
| 82 | 83 | */ |
| 83 | 84 | export const DE_FORGE: Level = levelOf("de_forge", grid); |
| 85 | ||
| 86 | /** | |
| 87 | * The level a map name stands for: de_forge, or the one a generated name carves. | |
| 88 | * A room's server and a member's page both build their level through here — | |
| 89 | * `levelOf` in `src/rooms/room.ts` and the `hello` handler in `main.ts` — because | |
| 90 | * a page that predicts its own movement is only right while it walks into the | |
| 91 | * same walls the server does, and one function is the cheapest way to keep two | |
| 92 | * builds of a level from ever being two different levels. | |
| 93 | */ | |
| 94 | export const levelNamed = (map: string): Level => (map === "de_forge" ? DE_FORGE : generateLevel(seedOf(map) ?? 0)); |
src/client/predict.test.ts added +324
| @@ -0,0 +1,324 @@ | ||
| 1 | /** | |
| 2 | * Prediction, held to the room it predicts. The first test is the one that | |
| 3 | * matters: a real `Room` stepped headless on a scripted three seconds of keys — | |
| 4 | * running, strafing while turning, a jump, a crouch, a walk, a switch to the | |
| 5 | * knife — with a quarter of a second of round trip between it and a page, and | |
| 6 | * the page's predictor fed the snapshots that room wrote. At every snapshot the | |
| 7 | * correction is under `EPSILON`, and at every tick the body the page drew is | |
| 8 | * where the room put it once that input arrived. That is the claim that the | |
| 9 | * page runs the server's physics and not a second, subtly different one. | |
| 10 | * | |
| 11 | * The rest are the rules around it, on the same real frames: the snapshot's | |
| 12 | * word on everything that is not movement, off while dead and before a frame | |
| 13 | * says what it answers, other bodies untouched, the history capped, and a | |
| 14 | * correction eased under `SNAP_UNITS` and snapped over it. | |
| 15 | */ | |
| 16 | ||
| 17 | import { expect, test } from "bun:test"; | |
| 18 | import { | |
| 19 | applySnapshot, readInput, writeInput, writeSnapshot, SNAPSHOT_MAX, SWITCH_KNIFE, SWITCH_NONE, | |
| 20 | type Pressed, type Snapshot, | |
| 21 | } from "../rooms/wire.ts"; | |
| 22 | import { createRoom, drive, helloFor, join, snapshotFor, stepRoom, type Room } from "../rooms/room.ts"; | |
| 23 | import type { Vec3 } from "../engine/index.ts"; | |
| 24 | import { nameOf } from "./generate.ts"; | |
| 25 | import { levelNamed } from "./map.ts"; | |
| 26 | import { COMPETITIVE, SHORT } from "./match.ts"; | |
| 27 | import { | |
| 28 | createPrediction, keep, place, reconcile, shown, EASE_MS, HISTORY_TICKS, SNAP_UNITS, | |
| 29 | type Prediction, | |
| 30 | } from "./predict.ts"; | |
| 31 | import { useShipped } from "./shipped.ts"; | |
| 32 | import { createWorld, NO_INPUT, type Input, type Player, type World } from "./world.ts"; | |
| 33 | ||
| 34 | useShipped(); // the room's bots think, as they do in a deployed room | |
| 35 | ||
| 36 | /** | |
| 37 | * How close the page's body has to be to the room's, in units. Positions cross | |
| 38 | * the wire as `f32`, which on a map a few thousand units across is a few | |
| 39 | * ten-thousandths of a unit; a hundredth is that with room to spare and still | |
| 40 | * a fiftieth of a pixel at any distance a body is drawn from its own eye. | |
| 41 | */ | |
| 42 | const EPSILON = 0.01; | |
| 43 | ||
| 44 | /** Ticks each way between page and room: 8 up and 8 down is a 250 ms round trip. */ | |
| 45 | const LATENCY = 8; | |
| 46 | ||
| 47 | /** A room on de_forge, one member seated, the freeze set aside. */ | |
| 48 | function seated(map: "de_forge" | string = "de_forge"): { room: Room; id: string; seat: number } { | |
| 49 | const room = createRoom("PRDICT", { map: map as "de_forge", seed: 9, length: "short", now: 0 }); | |
| 50 | room.world.match.phase = "live"; | |
| 51 | room.world.match.timer = room.rules.roundTime; | |
| 52 | const member = join(room, "member", "you", 0); | |
| 53 | if (member === null) throw new Error("the room had no seat"); | |
| 54 | return { room, id: member.id, seat: member.seat }; | |
| 55 | } | |
| 56 | ||
| 57 | /** The page's world, built the way `main.ts` builds it from `hello`. */ | |
| 58 | function page(room: Room, seat: number): World { | |
| 59 | const hello = helloFor(room, room.members.get("member") as NonNullable<ReturnType<typeof room.members.get>>); | |
| 60 | const world = createWorld(levelNamed(hello.map), hello.rules === "long" ? COMPETITIVE : SHORT, "you", "ct", 5, hello.seed); | |
| 61 | world.me = world.players[seat] as Player; | |
| 62 | world.me.bot = false; | |
| 63 | return world; | |
| 64 | } | |
| 65 | ||
| 66 | /** Tick `t` of the script: what the hand is doing. */ | |
| 67 | function script(t: number): { input: Input; yaw: number; switchCode: number } { | |
| 68 | const input: Input = { ...NO_INPUT }; | |
| 69 | let yaw = 90; | |
| 70 | if (t < 40) input.forward = 1; | |
| 71 | else if (t < 90) { | |
| 72 | input.forward = 1; | |
| 73 | input.side = 1; | |
| 74 | yaw = 90 + (t - 40) * 1.7; // strafing while turning | |
| 75 | } else if (t < 110) { | |
| 76 | input.forward = 1; | |
| 77 | yaw = 175; | |
| 78 | input.jump = t === 95; | |
| 79 | } else if (t < 140) { | |
| 80 | input.forward = -1; | |
| 81 | input.duck = true; | |
| 82 | yaw = 175; | |
| 83 | } else if (t < 170) { | |
| 84 | input.side = -1; | |
| 85 | input.walk = true; | |
| 86 | yaw = 200; | |
| 87 | } else if (t < 185) { | |
| 88 | yaw = 200; // hands off: friction brings the body to rest | |
| 89 | } else { | |
| 90 | input.forward = 0.6; // a stick, half forward | |
| 91 | yaw = 260; | |
| 92 | } | |
| 93 | return { input, yaw, switchCode: t === 150 ? SWITCH_KNIFE : SWITCH_NONE }; | |
| 94 | } | |
| 95 | ||
| 96 | const TICKS = 192; | |
| 97 | ||
| 98 | const copy = (v: Vec3): Vec3 => [v[0], v[1], v[2]]; | |
| 99 | const far = (a: Vec3, b: Vec3): number => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); | |
| 100 | ||
| 101 | test("the page's body is the room's body, at every snapshot and every tick", () => { | |
| 102 | const { room, id, seat } = seated(); | |
| 103 | const world = page(room, seat); | |
| 104 | const p = createPrediction(); | |
| 105 | const up: { at: number; bytes: Uint8Array }[] = []; | |
| 106 | const down: { at: number; bytes: Uint8Array }[] = []; | |
| 107 | /** Where the page drew the body after pressing tick k, while predicting. */ | |
| 108 | const drawn = new Map<number, Vec3>(); | |
| 109 | /** Where the room had the body after stepping the input stamped k. */ | |
| 110 | const truth = new Map<number, Vec3>(); | |
| 111 | const corrections: number[] = []; | |
| 112 | ||
| 113 | // The page presses on every tick of the run, as a page does on every frame: | |
| 114 | // one that fell silent would be a room holding its last keys for | |
| 115 | // `INPUT_HOLD` ticks while the page stood still, which is a stall and not | |
| 116 | // physics. Only the ticks the room has stepped by the end are compared. | |
| 117 | for (let t = 1; t <= TICKS + 3 * LATENCY; t++) { | |
| 118 | // The page: one frame of keys, kept as the room will read it, and moved by. | |
| 119 | { | |
| 120 | const s = script(t); | |
| 121 | const bytes = writeInput({ tick: t, input: s.input, yaw: s.yaw, pitch: 0, switchCode: s.switchCode, buy: null }); | |
| 122 | const sent = readInput(bytes) as Pressed; | |
| 123 | keep(p, sent, 1, world, world.me); | |
| 124 | if (p.on) drawn.set(t, copy(p.body.pos)); | |
| 125 | up.push({ at: t + LATENCY, bytes: new Uint8Array(bytes) }); | |
| 126 | } | |
| 127 | // The room: whatever has arrived, one step, and a snapshot every fourth. | |
| 128 | while (up.length > 0 && (up[0] as { at: number }).at <= t) drive(room, id, (up.shift() as { bytes: Uint8Array }).bytes); | |
| 129 | stepRoom(room); | |
| 130 | const body = room.world.players[seat] as Player; | |
| 131 | const applied = room.applied.get(seat); | |
| 132 | if (applied !== undefined && !truth.has(applied)) truth.set(applied, copy(body.pos)); | |
| 133 | if (room.tick % 4 === 0) down.push({ at: t + LATENCY, bytes: new Uint8Array(snapshotFor(room, room.members.get(id) as never)) }); | |
| 134 | // The page again: whatever snapshots have come back. | |
| 135 | while (down.length > 0 && (down[0] as { at: number }).at <= t) { | |
| 136 | const got = applySnapshot(world, (down.shift() as { bytes: Uint8Array }).bytes, seat) as Snapshot; | |
| 137 | const wasOn = p.on; | |
| 138 | const error = reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, t * 15.625); | |
| 139 | if (wasOn) corrections.push(error); | |
| 140 | } | |
| 141 | // Nothing shot the body: the one thing a replay cannot know about is a hit. | |
| 142 | expect(body.health).toBe(100); | |
| 143 | expect(body.tag).toBe(0); | |
| 144 | } | |
| 145 | ||
| 146 | // The script really went somewhere, and really was predicted. | |
| 147 | expect(drawn.size).toBeGreaterThan(TICKS - 4 * LATENCY); | |
| 148 | expect(corrections.length).toBeGreaterThan(40); | |
| 149 | const start = truth.get(1) as Vec3; | |
| 150 | const end = truth.get(TICKS) as Vec3; | |
| 151 | expect(far(start, end)).toBeGreaterThan(200); | |
| 152 | ||
| 153 | let worst = 0; | |
| 154 | let compared = 0; | |
| 155 | for (const [k, at] of drawn) { | |
| 156 | const was = truth.get(k); | |
| 157 | if (was === undefined) continue; | |
| 158 | compared += 1; | |
| 159 | worst = Math.max(worst, far(at, was as Vec3)); | |
| 160 | } | |
| 161 | expect(compared).toBeGreaterThan(TICKS); | |
| 162 | const most = Math.max(...corrections); | |
| 163 | console.log(` prediction against the room: worst tick ${worst.toExponential(2)} u, worst correction ${most.toExponential(2)} u, over ${compared} ticks and ${corrections.length} snapshots`); | |
| 164 | expect(worst).toBeLessThan(EPSILON); | |
| 165 | expect(most).toBeLessThan(EPSILON); | |
| 166 | }); | |
| 167 | ||
| 168 | test("the level a page builds from hello is the level the room stands on", () => { | |
| 169 | // Asserted rather than assumed: a replay against other walls is other physics. | |
| 170 | for (const map of ["de_forge", nameOf(31)]) { | |
| 171 | const room = createRoom("LEVELS", { map: map as "de_forge", seed: 3, length: "short", now: 0 }); | |
| 172 | const member = join(room, "m", "you", 0) as NonNullable<ReturnType<typeof join>>; | |
| 173 | const hello = helloFor(room, member); | |
| 174 | expect(levelNamed(hello.map).boxes).toEqual(room.level.boxes); | |
| 175 | } | |
| 176 | }); | |
| 177 | ||
| 178 | /** A seated room stepped a second with the member holding W, and the page's world after one snapshot. */ | |
| 179 | function played(): { room: Room; id: string; seat: number; world: World; p: Prediction; now: number } { | |
| 180 | const { room, id, seat } = seated(); | |
| 181 | const world = page(room, seat); | |
| 182 | const p = createPrediction(); | |
| 183 | let now = 0; | |
| 184 | for (let t = 1; t <= 64; t++) { | |
| 185 | const bytes = writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }); | |
| 186 | keep(p, readInput(bytes) as Pressed, 1, world, world.me); | |
| 187 | drive(room, id, bytes); | |
| 188 | stepRoom(room); | |
| 189 | now += 15.625; | |
| 190 | } | |
| 191 | const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot; | |
| 192 | reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now); | |
| 193 | return { room, id, seat, world, p, now }; | |
| 194 | } | |
| 195 | ||
| 196 | test("health, armour, ammunition, weapon and score are the snapshot's, and the replay never touches them", () => { | |
| 197 | const { room, id, seat, world, p, now } = played(); | |
| 198 | const body = room.world.players[seat] as Player; | |
| 199 | // The server's word changes on everything that is not movement. | |
| 200 | body.health = 61; | |
| 201 | body.armor = 44; | |
| 202 | body.weapon = "knife"; | |
| 203 | body.ammo.knife = { mag: 0, reserve: 0 }; | |
| 204 | body.kills = 3; | |
| 205 | body.money = 2750; | |
| 206 | room.world.match.score = { t: 2, ct: 5 }; | |
| 207 | const own = body.secondary; | |
| 208 | body.ammo[own] = { mag: 7, reserve: 24 }; | |
| 209 | const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot; | |
| 210 | // A few inputs the room has not seen, replayed on top of that snapshot and | |
| 211 | // stepped again after it: the replay has every chance to write something. | |
| 212 | for (let t = 65; t <= 70; t++) { | |
| 213 | const bytes = writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: SWITCH_KNIFE, buy: null }); | |
| 214 | keep(p, readInput(bytes) as Pressed, 1, world, world.me); | |
| 215 | } | |
| 216 | reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now); | |
| 217 | place(p, world.me, now + 5); | |
| 218 | const me = world.me; | |
| 219 | expect(me.health).toBe(61); | |
| 220 | expect(me.armor).toBe(44); | |
| 221 | expect(me.weapon).toBe("knife"); | |
| 222 | expect(me.kills).toBe(3); | |
| 223 | expect(me.money).toBe(2750); | |
| 224 | expect(world.match.score).toEqual({ t: 2, ct: 5 }); | |
| 225 | expect(me.ammo.knife).toEqual({ mag: 0, reserve: 0 }); | |
| 226 | // And the position is the prediction's, six ticks past the server's. | |
| 227 | expect(p.on).toBe(true); | |
| 228 | expect(far(me.pos, got.own?.pos as Vec3)).toBeGreaterThan(5); | |
| 229 | }); | |
| 230 | ||
| 231 | test("inputs the snapshot answers are dropped, and the ones after it kept", () => { | |
| 232 | const { room, id, seat, world, p, now } = played(); | |
| 233 | for (let t = 65; t <= 72; t++) | |
| 234 | keep(p, readInput(writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null })) as Pressed, 1, world, world.me); | |
| 235 | // The room has seen up to 68. | |
| 236 | for (let t = 65; t <= 68; t++) { | |
| 237 | drive(room, id, writeInput({ tick: t, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null })); | |
| 238 | stepRoom(room); | |
| 239 | } | |
| 240 | const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot; | |
| 241 | expect(got.ack).toBe(68); | |
| 242 | const error = reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now); | |
| 243 | expect(p.kept.map((k) => k.pressed.tick)).toEqual([69, 70, 71, 72]); | |
| 244 | expect(error).toBeLessThan(EPSILON); | |
| 245 | }); | |
| 246 | ||
| 247 | test("the history holds the ticks a worst-case round trip covers, and no more", () => { | |
| 248 | const { world, p } = played(); | |
| 249 | for (let t = 100; t < 100 + HISTORY_TICKS * 3; t++) | |
| 250 | keep(p, { tick: t, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me); | |
| 251 | expect(p.kept.length).toBe(HISTORY_TICKS); | |
| 252 | expect((p.kept[0] as { pressed: Pressed }).pressed.tick).toBe(100 + HISTORY_TICKS * 2); | |
| 253 | // A frame worth two ticks counts as two of them. | |
| 254 | keep(p, { tick: 999, input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 2, world, world.me); | |
| 255 | expect(p.kept.reduce((n, k) => n + k.steps, 0)).toBeLessThanOrEqual(HISTORY_TICKS); | |
| 256 | expect(HISTORY_TICKS).toBe(36); | |
| 257 | }); | |
| 258 | ||
| 259 | test("prediction is off before a frame says what it answers, and while dead", () => { | |
| 260 | const { room, id, seat } = seated(); | |
| 261 | const world = page(room, seat); | |
| 262 | const p = createPrediction(); | |
| 263 | const before = copy(world.me.pos); | |
| 264 | keep(p, { tick: 1, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me); | |
| 265 | // Before any snapshot nothing moves: there is nothing to predict from. | |
| 266 | expect(p.on).toBe(false); | |
| 267 | place(p, world.me, 0); | |
| 268 | expect(world.me.pos).toEqual(before); | |
| 269 | ||
| 270 | // A frame from a server that does not send the acknowledgement: taken, not replayed onto. | |
| 271 | const bare = writeSnapshot(room.world, room.tick, 0, new Uint8Array(SNAPSHOT_MAX)); | |
| 272 | const got = applySnapshot(world, bare, seat) as Snapshot; | |
| 273 | expect(got.ack).toBe(null); | |
| 274 | reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, 0); | |
| 275 | expect(p.on).toBe(false); | |
| 276 | expect(p.kept).toEqual([]); | |
| 277 | ||
| 278 | // Dead: the server's position, however many keys are held. | |
| 279 | const body = room.world.players[seat] as Player; | |
| 280 | body.alive = false; | |
| 281 | body.health = 0; | |
| 282 | const dead = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot; | |
| 283 | keep(p, { tick: 2, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me); | |
| 284 | reconcile(p, dead.own as NonNullable<Snapshot["own"]>, dead.ack, world, world.me, 0); | |
| 285 | expect(p.on).toBe(false); | |
| 286 | keep(p, { tick: 3, input: { ...NO_INPUT, forward: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null }, 1, world, world.me); | |
| 287 | expect(p.body.pos).toEqual(dead.own?.pos as Vec3); | |
| 288 | }); | |
| 289 | ||
| 290 | test("every body that is not yours is the snapshot's, prediction or not", () => { | |
| 291 | const { room, id, seat, world, p, now } = played(); | |
| 292 | for (let t = 65; t <= 70; t++) | |
| 293 | keep(p, readInput(writeInput({ tick: t, input: { ...NO_INPUT, side: 1 }, yaw: 0, pitch: 0, switchCode: 0, buy: null })) as Pressed, 1, world, world.me); | |
| 294 | const got = applySnapshot(world, snapshotFor(room, room.members.get(id) as never), seat) as Snapshot; | |
| 295 | reconcile(p, got.own as NonNullable<Snapshot["own"]>, got.ack, world, world.me, now); | |
| 296 | place(p, world.me, now); | |
| 297 | room.world.players.forEach((q, i) => { | |
| 298 | if (i === seat) return; | |
| 299 | const mine = world.players[i] as Player; | |
| 300 | expect(far(mine.pos, q.pos)).toBeLessThan(EPSILON); | |
| 301 | }); | |
| 302 | }); | |
| 303 | ||
| 304 | test("a correction under the threshold is eased out over EASE_MS, one over it snaps", () => { | |
| 305 | const { world, p, now } = played(); | |
| 306 | const at = copy(p.body.pos); | |
| 307 | const own = { pos: copy(at), vel: [0, 0, 0] as Vec3 }; | |
| 308 | // The server says ten units east of where the page had you. | |
| 309 | own.pos[0] += 10; | |
| 310 | const error = reconcile(p, own, 64 + HISTORY_TICKS, world, world.me, now); | |
| 311 | expect(error).toBeCloseTo(10, 5); | |
| 312 | // On the frame of the correction you are still drawn where you were… | |
| 313 | expect(far(shown(p, now), at)).toBeLessThan(1e-9); | |
| 314 | // …halfway through the ease, halfway there… | |
| 315 | expect(shown(p, now + EASE_MS / 2)[0]).toBeCloseTo(at[0] + 5, 5); | |
| 316 | // …and at the end of it, exactly on the server's word. | |
| 317 | expect(shown(p, now + EASE_MS)).toEqual(own.pos); | |
| 318 | ||
| 319 | // A correction wider than the body is not a stumble, and is not eased. | |
| 320 | const later = now + 1000; | |
| 321 | const jump = { pos: [own.pos[0] + SNAP_UNITS + 1, own.pos[1], own.pos[2]] as Vec3, vel: [0, 0, 0] as Vec3 }; | |
| 322 | expect(reconcile(p, jump, 64 + HISTORY_TICKS, world, world.me, later)).toBeGreaterThan(SNAP_UNITS); | |
| 323 | expect(shown(p, later)).toEqual(jump.pos); | |
| 324 | }); |
src/client/predict.ts added +212
| @@ -0,0 +1,212 @@ | ||
| 1 | /** | |
| 2 | * Your own body, ahead of the network: the inputs you have sent and the server | |
| 3 | * has not answered yet, and the replay that moves you by them. | |
| 4 | * | |
| 5 | * In a room your position is the server's word, and the server's word arrives | |
| 6 | * a round trip after your hand moved. So the page keeps every input it sends, | |
| 7 | * stamped the way it went up, and steps your body by it at once; when a | |
| 8 | * snapshot arrives it says which of those inputs it is the result of (`ack` in | |
| 9 | * `src/rooms/wire.ts`), the body is put back where the server has it, and every | |
| 10 | * input the server has not seen yet is played again on top. Done right, the | |
| 11 | * replay lands exactly where the page already had you and nothing moves. | |
| 12 | * | |
| 13 | * "Done right" is the whole of this file, and it is three things: | |
| 14 | * | |
| 15 | * - **The same mover.** One tick is `move` from the engine on `CS_MOVE` and | |
| 16 | * `CS_HULL`, handed the wish `wishOf` in `src/client/world.ts` builds — the | |
| 17 | * function the server's own `applyInput` builds it with — against the level | |
| 18 | * the page built from `hello` through the server's own builder. | |
| 19 | * - **The same input.** What is kept is not what the page meant to send: it is | |
| 20 | * the frame *as the server reads it*, the bytes `writeInput` wrote put back | |
| 21 | * through `readInput`, so the yaw is the sixteen-bit yaw and a stick's | |
| 22 | * forward is the seven-bit forward the room steps with. | |
| 23 | * - **Movement only.** Where you stand, how fast, whether you are on the floor | |
| 24 | * and how far down you are crouched. Health, armour, ammunition, the weapon, | |
| 25 | * money and score are the snapshot's and nothing in here writes them; a shot | |
| 26 | * or a hit is never predicted, because a page that predicts damage is a page | |
| 27 | * that lies to its player. | |
| 28 | * | |
| 29 | * What it does not know, and so gets wrong until the next snapshot corrects it: | |
| 30 | * the slow a hit puts on you (`tag`, which does not cross the wire), a round | |
| 31 | * that starts or ends inside the round trip, and a server that held your last | |
| 32 | * input over a tick your next one was late for. Each of those is a correction, | |
| 33 | * and a correction is eased or snapped by the two numbers below. | |
| 34 | * | |
| 35 | * Nothing in here is a socket or a browser: a prediction is plain data, and | |
| 36 | * every function takes it and the world and hands back numbers, so | |
| 37 | * `predict.test.ts` steps a real room and this side by side with neither. | |
| 38 | */ | |
| 39 | ||
| 40 | import { move, type Body, type Vec3 } from "../engine/index.ts"; | |
| 41 | import { resolveSwitch, type Pressed } from "../rooms/wire.ts"; | |
| 42 | import { CS_HULL, CS_MOVE } from "./movement.ts"; | |
| 43 | import { wishOf, type Player, type World } from "./world.ts"; | |
| 44 | import type { WeaponId } from "./weapons.ts"; | |
| 45 | ||
| 46 | /** The fixed step both ends move a body by: the game's own 64 Hz. */ | |
| 47 | export const TICK = 1 / 64; | |
| 48 | ||
| 49 | /** | |
| 50 | * The worst round trip prediction is built to cover, in milliseconds: the | |
| 51 | * point at which the page already says the connection is behind (`BEHIND_MS` | |
| 52 | * in `session.ts`). Past it the page is honest about lagging, and pretending to | |
| 53 | * hide it would only make the snap at the end of it bigger. | |
| 54 | */ | |
| 55 | export const WORST_TRIP_MS = 500; | |
| 56 | ||
| 57 | /** | |
| 58 | * How many ticks of input are kept: the worst round trip, plus the sixteenth of | |
| 59 | * a second a snapshot can wait on the server before it is sent (`SNAPSHOT_HZ`). | |
| 60 | * 36 ticks. An input older than that is one the server has either answered or | |
| 61 | * lost, and dropping it costs a correction and nothing else. | |
| 62 | */ | |
| 63 | export const HISTORY_TICKS = Math.ceil((WORST_TRIP_MS + 1000 / 16) / (1000 * TICK)); | |
| 64 | ||
| 65 | /** | |
| 66 | * Under this many units of error the correction is eased out; over it the body | |
| 67 | * snaps in one frame. 32 is the width of the hull. A miss smaller than your own | |
| 68 | * body is a held key a tick too long or a hit's slow the page did not know about | |
| 69 | * — a stumble, which easing hides — and one wider than you is a spawn, a | |
| 70 | * teleport or a connection that stalled: you are somewhere else, and gliding | |
| 71 | * there would draw you through whatever stands in between. | |
| 72 | */ | |
| 73 | export const SNAP_UNITS = 32; | |
| 74 | ||
| 75 | /** | |
| 76 | * How long an eased correction takes, in milliseconds. 100 is a little over | |
| 77 | * one and a half snapshots (62.5 ms apart): long enough that a correction is a | |
| 78 | * drift rather than a jump at 60 fps, short enough that it is done before the | |
| 79 | * snapshot after next can land a second one on top of it. | |
| 80 | */ | |
| 81 | export const EASE_MS = 100; | |
| 82 | ||
| 83 | /** One input the server has not answered yet, and how many ticks the page moved by it. */ | |
| 84 | export type Kept = { pressed: Pressed; steps: number }; | |
| 85 | ||
| 86 | /** Your body as the replay has it, the inputs it has not been answered for, and the correction still showing. */ | |
| 87 | export type Prediction = { | |
| 88 | /** Whether the body below is a prediction at all. False before an acknowledged snapshot, and while dead. */ | |
| 89 | on: boolean; | |
| 90 | /** Where the replay has your body: the server's last word plus every input since. */ | |
| 91 | body: Body; | |
| 92 | /** The inputs sent and not acknowledged, oldest first. */ | |
| 93 | kept: Kept[]; | |
| 94 | /** The part of the last correction not yet eased out, in units. */ | |
| 95 | offset: Vec3; | |
| 96 | /** When that correction was made, on the page's clock in ms. */ | |
| 97 | since: number; | |
| 98 | /** The weapon the replay ended holding, which a switch inside the round trip may have changed. */ | |
| 99 | held: WeaponId | null; | |
| 100 | }; | |
| 101 | ||
| 102 | export const createPrediction = (): Prediction => ({ | |
| 103 | on: false, | |
| 104 | body: { pos: [0, 0, 0], vel: [0, 0, 0], onGround: false, ducking: false, duckAmount: 0 }, | |
| 105 | kept: [], | |
| 106 | offset: [0, 0, 0], | |
| 107 | since: 0, | |
| 108 | held: null, | |
| 109 | }); | |
| 110 | ||
| 111 | /** | |
| 112 | * One tick of your body under one input: the server's `applyInput`, movement | |
| 113 | * only. The weapon in your hands is carried separately because a switch in the | |
| 114 | * input changes the speed you run at on the tick it is pressed, as it does on | |
| 115 | * the server, and the body's own `weapon` is the snapshot's and not touched. | |
| 116 | */ | |
| 117 | function stepOnce(body: Body, pressed: Pressed, world: World, me: Player, held: WeaponId): WeaponId { | |
| 118 | // What the server resolves the switch against is the body's own holster, and | |
| 119 | // `resolveSwitch` is the server's function for it. | |
| 120 | const wanted = resolveSwitch(pressed.switchCode, me); | |
| 121 | const hands = wanted ?? held; | |
| 122 | // The slow of a hit is the server's and is not sent, so the replay runs at | |
| 123 | // the speed of a body nobody has shot: a correction if it has been. | |
| 124 | const wish = wishOf(world, { yaw: pressed.yaw, scoped: me.scoped, tag: 0, weapon: hands }, pressed.input); | |
| 125 | move(body, wish, TICK, world.level.boxes, CS_MOVE, CS_HULL); | |
| 126 | return hands; | |
| 127 | } | |
| 128 | ||
| 129 | /** | |
| 130 | * An input went up: keep it, and — while predicting — move by it now. `steps` | |
| 131 | * is how many 64 Hz ticks of the page's loop it covers, because a frame that | |
| 132 | * was worth two ticks sends one frame of keys and the server holds it for both. | |
| 133 | * The history is capped at `HISTORY_TICKS` of those, oldest dropped first. | |
| 134 | */ | |
| 135 | export function keep(p: Prediction, pressed: Pressed, steps: number, world: World, me: Player): void { | |
| 136 | const ticks = Math.max(1, Math.min(HISTORY_TICKS, Math.floor(steps))); | |
| 137 | p.kept.push({ pressed, steps: ticks }); | |
| 138 | let total = 0; | |
| 139 | for (const k of p.kept) total += k.steps; | |
| 140 | while (total > HISTORY_TICKS && p.kept.length > 0) total -= (p.kept.shift() as Kept).steps; | |
| 141 | if (!p.on) return; | |
| 142 | let held = p.held ?? me.weapon; | |
| 143 | for (let i = 0; i < ticks; i++) held = stepOnce(p.body, pressed, world, me, held); | |
| 144 | p.held = held; | |
| 145 | } | |
| 146 | ||
| 147 | /** Where the body is drawn at `now`: the replay's position plus what is left of the last correction. */ | |
| 148 | export function shown(p: Prediction, now: number): Vec3 { | |
| 149 | const left = Math.max(0, 1 - (now - p.since) / EASE_MS); | |
| 150 | return [p.body.pos[0] + p.offset[0] * left, p.body.pos[1] + p.offset[1] * left, p.body.pos[2] + p.offset[2] * left]; | |
| 151 | } | |
| 152 | ||
| 153 | /** | |
| 154 | * A snapshot arrived. `own` is where the server has your body, `ack` which of | |
| 155 | * your inputs that is the result of, and `me` your body in the world the | |
| 156 | * snapshot was just read into — its floor contact, its crouch, its weapon and | |
| 157 | * whether it is alive are the snapshot's. The body goes back to the server's | |
| 158 | * word, every input after `ack` is played again, and the difference between | |
| 159 | * where you were drawn and where the replay puts you is eased or snapped. | |
| 160 | * | |
| 161 | * Returns how far the correction was, in units: 0 is a prediction that was | |
| 162 | * exactly right, which is what `predict.test.ts` holds a room to. | |
| 163 | */ | |
| 164 | export function reconcile( | |
| 165 | p: Prediction, own: { pos: Vec3; vel: Vec3 }, ack: number | null, world: World, me: Player, now: number, | |
| 166 | ): number { | |
| 167 | const was = p.on ? shown(p, now) : null; | |
| 168 | p.body.pos = [own.pos[0], own.pos[1], own.pos[2]]; | |
| 169 | p.body.vel = [own.vel[0], own.vel[1], own.vel[2]]; | |
| 170 | p.body.onGround = me.onGround; | |
| 171 | p.body.ducking = me.ducking; | |
| 172 | p.body.duckAmount = me.duckAmount; | |
| 173 | // Off where it would lie: a dead body is not moved by anybody's keys, and a | |
| 174 | // frame that does not say which input it answers cannot be replayed onto. | |
| 175 | if (!me.alive || ack === null) { | |
| 176 | p.on = false; | |
| 177 | p.kept.length = 0; | |
| 178 | p.offset = [0, 0, 0]; | |
| 179 | p.held = null; | |
| 180 | return 0; | |
| 181 | } | |
| 182 | let drop = 0; | |
| 183 | while (drop < p.kept.length && (p.kept[drop] as Kept).pressed.tick <= ack) drop += 1; | |
| 184 | if (drop > 0) p.kept.splice(0, drop); | |
| 185 | let held = me.weapon; | |
| 186 | for (const k of p.kept) for (let i = 0; i < k.steps; i++) held = stepOnce(p.body, k.pressed, world, me, held); | |
| 187 | p.held = held; | |
| 188 | p.on = true; | |
| 189 | if (was === null) { | |
| 190 | p.offset = [0, 0, 0]; | |
| 191 | return 0; | |
| 192 | } | |
| 193 | const off: Vec3 = [was[0] - p.body.pos[0], was[1] - p.body.pos[1], was[2] - p.body.pos[2]]; | |
| 194 | const error = Math.hypot(off[0], off[1], off[2]); | |
| 195 | p.offset = error > SNAP_UNITS ? [0, 0, 0] : off; | |
| 196 | p.since = now; | |
| 197 | return error; | |
| 198 | } | |
| 199 | ||
| 200 | /** | |
| 201 | * The predicted body onto your own `Player`, for the frame to draw: the eased | |
| 202 | * position, the replay's velocity, floor contact and crouch — and nothing else. | |
| 203 | * A prediction that is off writes nothing, and the body keeps the server's. | |
| 204 | */ | |
| 205 | export function place(p: Prediction, me: Player, now: number): void { | |
| 206 | if (!p.on) return; | |
| 207 | me.pos = shown(p, now); | |
| 208 | me.vel = [p.body.vel[0], p.body.vel[1], p.body.vel[2]]; | |
| 209 | me.onGround = p.body.onGround; | |
| 210 | me.ducking = p.body.ducking; | |
| 211 | me.duckAmount = p.body.duckAmount; | |
| 212 | } |
src/client/session.test.ts +74
| @@ -628,3 +628,77 @@ test("keys pressed after close go nowhere", () => { | ||
| 628 | 628 | s.session.press(pressed({ input: { ...NO_INPUT, fire: true } })); |
| 629 | 629 | expect(s.sent).toHaveLength(1); |
| 630 | 630 | }); |
| 631 | ||
| 632 | // --- your own body, predicted ------------------------------------------------ | |
| 633 | ||
| 634 | /** A snapshot that says which of our inputs it answers, the way a room writes one. */ | |
| 635 | const answered = (world: World, ack: number, tick = 1): Uint8Array => | |
| 636 | writeSnapshot(world, tick, 0, new Uint8Array(SNAPSHOT_MAX), ack); | |
| 637 | ||
| 638 | test("your body moves on the frame you press, not a round trip later", () => { | |
| 639 | const s = stage(); | |
| 640 | s.join(); | |
| 641 | const room = tenBodies("room"); | |
| 642 | room.match.phase = "live"; | |
| 643 | s.say(answered(room, 0)); | |
| 644 | const world = worldOf(s); | |
| 645 | const from = [...body(room, SEAT).pos]; | |
| 646 | // Ten ticks of W looking along +x, and no snapshot back yet. | |
| 647 | for (let i = 0; i < 5; i++) s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 }, yaw: 0 }), 2); | |
| 648 | s.session.place(s.clock); | |
| 649 | expect(s.session.prediction().on).toBe(true); | |
| 650 | expect(s.session.prediction().kept.map((k) => k.steps)).toEqual([2, 2, 2, 2, 2]); | |
| 651 | expect(world.me.pos[0] - (from[0] as number)).toBeGreaterThan(10); | |
| 652 | expect(Math.abs(world.me.pos[1] - (from[1] as number))).toBeLessThan(0.01); | |
| 653 | // Nobody else moved: the other nine are the snapshot's. | |
| 654 | expect(body(world, 0).pos).toEqual(body(room, 0).pos); | |
| 655 | }); | |
| 656 | ||
| 657 | test("a snapshot that answers every input puts you back on the server's word", () => { | |
| 658 | const s = stage(); | |
| 659 | s.join(); | |
| 660 | const room = tenBodies("room"); | |
| 661 | room.match.phase = "live"; | |
| 662 | s.say(answered(room, 0)); | |
| 663 | for (let i = 0; i < 3; i++) s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } })); | |
| 664 | // The server answered all three and says you did not move (a wall, say): the | |
| 665 | // couple of units the page had you over is under `SNAP_UNITS`, so it is eased… | |
| 666 | body(room, SEAT).health = 57; | |
| 667 | s.say(answered(room, 3, 2)); | |
| 668 | const world = worldOf(s); | |
| 669 | expect(s.session.prediction().kept).toEqual([]); | |
| 670 | expect(world.me.health).toBe(57); | |
| 671 | expect(world.me.pos[0]).toBeGreaterThan((body(room, SEAT).pos[0]) + 1); | |
| 672 | // …and a tenth of a second later you are exactly where the server has you. | |
| 673 | s.clock += 100; | |
| 674 | s.session.place(s.clock); | |
| 675 | expect(world.me.pos).toEqual(body(room, SEAT).pos); | |
| 676 | }); | |
| 677 | ||
| 678 | test("a frame that answers nothing is taken as it is, and nothing is predicted", () => { | |
| 679 | const s = stage(); | |
| 680 | s.join(); | |
| 681 | const room = tenBodies("room"); | |
| 682 | move(room); | |
| 683 | s.say(answered(room, 0)); | |
| 684 | s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } })); | |
| 685 | // A server on the previous deploy writes no acknowledgement. | |
| 686 | s.say(snapshotOf(room, 2)); | |
| 687 | s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } })); | |
| 688 | s.session.place(s.clock); | |
| 689 | expect(s.session.prediction().on).toBe(false); | |
| 690 | expect(worldOf(s).me.pos).toEqual([292, 104, 64]); | |
| 691 | }); | |
| 692 | ||
| 693 | test("a dead body is the server's, whatever keys are held", () => { | |
| 694 | const s = stage(); | |
| 695 | s.join(); | |
| 696 | const room = tenBodies("room"); | |
| 697 | move(room); | |
| 698 | body(room, SEAT).alive = false; | |
| 699 | s.say(answered(room, 0)); | |
| 700 | s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 } })); | |
| 701 | s.session.place(s.clock); | |
| 702 | expect(s.session.prediction().on).toBe(false); | |
| 703 | expect(worldOf(s).me.pos).toEqual([292, 104, 64]); | |
| 704 | }); |
src/client/session.ts +56 −14
| @@ -7,15 +7,18 @@ | ||
| 7 | 7 | * (`src/rooms/room.ts`) and this fills a local world from the snapshots that |
| 8 | 8 | * arrive, so everything already built on a `World` — the scene, the HUD, the |
| 9 | 9 | * minimap, the sounds, the spectator camera — draws a room without knowing it |
| 10 | * is one. One thing is the browser's own even so: the view angles, which the | |
| 11 | * frame writes on `world.me` and sends upstream, so aim answers the hand rather | |
| 12 | * than the network. Your body's position is the server's word, arriving 16 | |
| 13 | * times a second — which is honest and is also the lag a player feels in a room | |
| 14 | * today. Predicting your own movement is #305's remaining half and is | |
| 15 | * deliberately not faked here. | |
| 10 | * is one. Two things are the browser's own even so. One is the view angles, | |
| 11 | * which the frame writes on `world.me` and sends upstream, so aim answers the | |
| 12 | * hand rather than the network. The other is your body's movement: every input that goes | |
| 13 | * up is also kept and stepped here at once through `src/client/predict.ts`, and | |
| 14 | * each snapshot — which says which of those inputs it answers — puts the body | |
| 15 | * back on the server's word and plays the rest again on top. Movement only: | |
| 16 | * health, armour, ammunition, the weapon and the score are the snapshot's, and | |
| 17 | * a dead body, a body that is not yours and a page before `hello` are not | |
| 18 | * predicted at all. | |
| 16 | 19 | * |
| 17 | * Nothing between two snapshots is smoothed either: a snapshot is written | |
| 18 | * straight onto the bodies, so every one of them moves in 62 ms steps and stands | |
| 20 | * Nobody else's body is smoothed between two snapshots: a snapshot is written | |
| 21 | * straight onto the other bodies, so every one of them moves in 62 ms steps and stands | |
| 19 | 22 | * still in between. Interpolating them is its own issue and is not pretended to |
| 20 | 23 | * here or anywhere else. |
| 21 | 24 | * |
| @@ -32,7 +35,8 @@ | ||
| 32 | 35 | |
| 33 | 36 | import { createMember, type MemberSocket } from "../engine/index.ts"; |
| 34 | 37 | import type { Down, Hello, Pressed, Snapshot } from "../rooms/wire.ts"; |
| 35 | import { applySnapshot, forget, readDown, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts"; | |
| 38 | import { applySnapshot, forget, readDown, readInput, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts"; | |
| 39 | import { createPrediction, keep, place, reconcile, type Prediction } from "./predict.ts"; | |
| 36 | 40 | import type { Player, World } from "./world.ts"; |
| 37 | 41 | |
| 38 | 42 | /** All this file asks a WebSocket to be — the engine's, under the name this game has always used. */ |
| @@ -65,8 +69,19 @@ export type Session = { | ||
| 65 | 69 | /** Which body is ours, or −1 before `hello`. */ |
| 66 | 70 | seat(): number; |
| 67 | 71 | standing(now: number): Standing; |
| 68 | /** One tick of keys upstream. Dropped silently while the socket is not open. */ | |
| 69 | press(pressed: Omit<Pressed, "tick">): void; | |
| 72 | /** | |
| 73 | * One frame of keys upstream, covering `steps` ticks of the page's loop (one | |
| 74 | * when not said), and your body moved by it at once. Dropped silently — sent | |
| 75 | * nowhere and not predicted — while the socket is not open. | |
| 76 | */ | |
| 77 | press(pressed: Omit<Pressed, "tick">, steps?: number): void; | |
| 78 | /** | |
| 79 | * Your body where this frame draws it: the prediction, with whatever is left | |
| 80 | * of the last correction eased in. Nothing while prediction is off. | |
| 81 | */ | |
| 82 | place(now: number): void; | |
| 83 | /** The prediction, for the tests and the pull's numbers to read. */ | |
| 84 | prediction(): Prediction; | |
| 70 | 85 | close(): void; |
| 71 | 86 | }; |
| 72 | 87 | |
| @@ -101,6 +116,9 @@ export function createSession(options: { | ||
| 101 | 116 | }): Session { |
| 102 | 117 | let seat = -1; |
| 103 | 118 | let people = 0; |
| 119 | let prediction = createPrediction(); | |
| 120 | /** How many ticks the frame being pressed covers, read by `writeInput` below. */ | |
| 121 | let steps = 1; | |
| 104 | 122 | let here: Here[] = []; |
| 105 | 123 | let side: "t" | "ct" | null = null; |
| 106 | 124 | |
| @@ -115,11 +133,21 @@ export function createSession(options: { | ||
| 115 | 133 | // A world only ever exists with a seat, so `seat` is ours by the time a |
| 116 | 134 | // snapshot is read. |
| 117 | 135 | readSnapshot: (world, bytes) => applySnapshot(world, bytes, seat), |
| 118 | writeInput: (pressed, tick, into) => writeInput({ ...pressed, tick }, into), | |
| 136 | // The input the replay moves by is the frame as the room will read it — | |
| 137 | // these bytes, put back through the room's own `readInput` — so a yaw is | |
| 138 | // the sixteen-bit yaw on both ends and never the page's float. | |
| 139 | writeInput: (pressed, tick, into) => { | |
| 140 | const bytes = writeInput({ ...pressed, tick }, into); | |
| 141 | const world = member.world(); | |
| 142 | const sent = readInput(bytes); | |
| 143 | if (world !== null && sent !== null) keep(prediction, sent, steps, world, world.me); | |
| 144 | return bytes; | |
| 145 | }, | |
| 119 | 146 | }, |
| 120 | 147 | hear(down, world) { |
| 121 | 148 | if (down.k === "hello") { |
| 122 | 149 | seat = down.seat; |
| 150 | prediction = createPrediction(); | |
| 123 | 151 | const built = options.build(down); |
| 124 | 152 | // A body nobody else is holding is a bot to everything that reads the |
| 125 | 153 | // world; ours is not, and it is the one the camera and the HUD follow. |
| @@ -143,8 +171,13 @@ export function createSession(options: { | ||
| 143 | 171 | }, |
| 144 | 172 | // Our own body: the server's position, handed over rather than written in |
| 145 | 173 | // (`wire.ts`), so this is the one place a page decides what to do with it. |
| 174 | // It is reconciled against; while prediction is off it is simply taken. | |
| 146 | 175 | landed(got, world) { |
| 147 | if (got.own !== null) { | |
| 176 | if (got.own === null) return; | |
| 177 | reconcile(prediction, got.own, got.ack, world, world.me, options.now()); | |
| 178 | if (prediction.on) { | |
| 179 | place(prediction, world.me, options.now()); | |
| 180 | } else { | |
| 148 | 181 | world.me.pos = got.own.pos; |
| 149 | 182 | world.me.vel = got.own.vel; |
| 150 | 183 | } |
| @@ -178,7 +211,16 @@ export function createSession(options: { | ||
| 178 | 211 | code: options.code, live: member.live(), behind: member.behind(now), line: member.line(now), people, |
| 179 | 212 | here, side, |
| 180 | 213 | }), |
| 181 | press: member.press, | |
| 214 | press(pressed, frames = 1) { | |
| 215 | steps = frames; | |
| 216 | member.press(pressed); | |
| 217 | steps = 1; | |
| 218 | }, | |
| 219 | place(now) { | |
| 220 | const world = member.world(); | |
| 221 | if (world !== null) place(prediction, world.me, now); | |
| 222 | }, | |
| 223 | prediction: () => prediction, | |
| 182 | 224 | close: member.close, |
| 183 | 225 | }; |
| 184 | 226 | } |
src/client/world.ts +25 −11
| @@ -499,7 +499,7 @@ function checkRoundEnd(world: World): void { | ||
| 499 | 499 | |
| 500 | 500 | // --- shooting ------------------------------------------------------------- |
| 501 | 501 | |
| 502 | export function currentWeapon(p: Player): Weapon { | |
| 502 | export function currentWeapon(p: Pick<Player, "weapon">): Weapon { | |
| 503 | 503 | return WEAPONS[p.weapon] ?? WEAPONS.knife; |
| 504 | 504 | } |
| 505 | 505 | |
| @@ -814,9 +814,31 @@ function land(world: World, p: Player, speed: number): void { | ||
| 814 | 814 | if (p.health <= 0) onKill(world, p, p, "fall", false); |
| 815 | 815 | } |
| 816 | 816 | |
| 817 | /** | |
| 818 | * What a body asks the mover for on one tick: the keys, gated by the round's | |
| 819 | * phase, the view it is looking along, and the speed its weapon and its wounds | |
| 820 | * allow. It is its own function because two places build it and they must build | |
| 821 | * the same one: `applyInput` below, on this world's own tick, and the replay in | |
| 822 | * `src/client/predict.ts`, which moves a member's own body ahead of the server | |
| 823 | * and is only honest while its wish is this wish. | |
| 824 | */ | |
| 825 | export function wishOf( | |
| 826 | world: Pick<World, "match">, p: Pick<Player, "yaw" | "scoped" | "tag" | "weapon">, input: Input, | |
| 827 | ): Wish { | |
| 828 | const frozen = world.match.phase === "freeze" || world.match.phase === "over"; | |
| 829 | return { | |
| 830 | forward: frozen ? 0 : input.forward, | |
| 831 | side: frozen ? 0 : input.side, | |
| 832 | yaw: p.yaw, | |
| 833 | jump: input.jump && !frozen, | |
| 834 | duck: input.duck, | |
| 835 | walk: input.walk || p.scoped, | |
| 836 | maxSpeed: Math.min(CS_MOVE.maxSpeed, currentWeapon(p).carrySpeed) * (1 - p.tag), | |
| 837 | }; | |
| 838 | } | |
| 839 | ||
| 817 | 840 | export function applyInput(world: World, p: Player, input: Input, dt: number): void { |
| 818 | 841 | if (!p.alive) return; |
| 819 | const frozen = world.match.phase === "freeze" || world.match.phase === "over"; | |
| 820 | 842 | if (input.switchTo !== null && (input.switchTo === "knife" || input.switchTo === p.primary || input.switchTo === p.secondary)) { |
| 821 | 843 | if (p.weapon !== input.switchTo) { |
| 822 | 844 | p.weapon = input.switchTo; |
| @@ -838,15 +860,7 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v | ||
| 838 | 860 | } |
| 839 | 861 | } |
| 840 | 862 | const w = currentWeapon(p); |
| 841 | const wish: Wish = { | |
| 842 | forward: frozen ? 0 : input.forward, | |
| 843 | side: frozen ? 0 : input.side, | |
| 844 | yaw: p.yaw, | |
| 845 | jump: input.jump && !frozen, | |
| 846 | duck: input.duck, | |
| 847 | walk: input.walk || p.scoped, | |
| 848 | maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed) * (1 - p.tag), | |
| 849 | }; | |
| 863 | const wish = wishOf(world, p, input); | |
| 850 | 864 | const landed = move(p, wish, dt, world.level.boxes, CS_MOVE, CS_HULL); |
| 851 | 865 | if (landed > 0) land(world, p, landed); |
| 852 | 866 | if (!p.alive) return; |
src/rooms/room.test.ts +19 −2
| @@ -16,11 +16,11 @@ import { | ||
| 16 | 16 | drive, freeSeat, isOver, join, leave, logSince, resultsOf, rosterOf, sideFrom, snapshotFor, stepRoom, uniqueName, |
| 17 | 17 | INPUT_HOLD, TEAM_SIZE, type Finished, type Member, type Room, |
| 18 | 18 | } from "./room.ts"; |
| 19 | import { writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts"; | |
| 19 | import { applySnapshot, writeInput, SWITCH_PRIMARY, SWITCH_SECONDARY } from "./wire.ts"; | |
| 20 | 20 | // The cap is the hub's, because the hub is what enforces it on a socket; a |
| 21 | 21 | // frame of that size is refused here too, and this is where that is read. |
| 22 | 22 | import { MESSAGE_MAX } from "./hub.ts"; |
| 23 | import { ENDING_TICKS, forward, lastRound, room } from "./stage.test-helper.ts"; | |
| 23 | import { ENDING_TICKS, forward, lastRound, room, watcher } from "./stage.test-helper.ts"; | |
| 24 | 24 | import { matchOf } from "../matchid.ts"; |
| 25 | 25 | import { parseResult, type Result } from "../result.ts"; |
| 26 | 26 | import { NO_INPUT, type Player } from "../client/world.ts"; |
| @@ -295,3 +295,20 @@ test("a member who left before match_over has no result, and the bot that finish | ||
| 295 | 295 | for (let i = 0; i < ENDING_TICKS; i++) stepRoom(r); |
| 296 | 296 | expect(resultsOf(r).map((f) => f.id)).toEqual(["stays"]); |
| 297 | 297 | }); |
| 298 | ||
| 299 | test("a member's snapshot answers the newest of their inputs the room stepped with", () => { | |
| 300 | const r = room(); | |
| 301 | const member = join(r, "a", "ada", 0) as NonNullable<ReturnType<typeof join>>; | |
| 302 | // Nothing stepped yet: the answer is none of them, which is zero. | |
| 303 | expect(applySnapshot(watcher(r), snapshotFor(r, member), member.seat)?.ack).toBe(0); | |
| 304 | drive(r, "a", forward(7, 0)); | |
| 305 | // Arrived and not yet stepped with is not answered. | |
| 306 | expect(applySnapshot(watcher(r), snapshotFor(r, member), member.seat)?.ack).toBe(0); | |
| 307 | stepRoom(r); | |
| 308 | drive(r, "a", forward(8, 0)); | |
| 309 | stepRoom(r); | |
| 310 | expect(applySnapshot(watcher(r), snapshotFor(r, member), member.seat)?.ack).toBe(8); | |
| 311 | // And leaving forgets it, so the next person in the seat starts from none. | |
| 312 | leave(r, "a", 0); | |
| 313 | expect(r.applied.has(member.seat)).toBe(false); | |
| 314 | }); |
src/rooms/room.ts +13 −6
| @@ -47,12 +47,11 @@ | ||
| 47 | 47 | * whole round with five members in it and no network at all. |
| 48 | 48 | */ |
| 49 | 49 | |
| 50 | import { DE_FORGE } from "../client/map.ts"; | |
| 51 | import { generateLevel } from "../client/generate.ts"; | |
| 50 | import { levelNamed } from "../client/map.ts"; | |
| 52 | 51 | import { buy as spend, canBuy } from "../client/economy.ts"; |
| 53 | 52 | import type { Level } from "../client/level.ts"; |
| 54 | 53 | import { COMPETITIVE, SHORT, type Rules, type Team } from "../client/match.ts"; |
| 55 | import { seedOf, type PlayedMap } from "../mapname.ts"; | |
| 54 | import type { PlayedMap } from "../mapname.ts"; | |
| 56 | 55 | import { playKey } from "../matchid.ts"; |
| 57 | 56 | import type { Result } from "../result.ts"; |
| 58 | 57 | import { |
| @@ -133,6 +132,12 @@ export type Room = { | ||
| 133 | 132 | pressed: Map<number, { pressed: Pressed; tick: number }>; |
| 134 | 133 | /** What each bot is called, by seat, so a leave gives the name back. */ |
| 135 | 134 | botNames: string[]; |
| 135 | /** | |
| 136 | * The stamp of the input each seat's body was last stepped with, by seat: | |
| 137 | * what a member's snapshot acknowledges, so their page knows which of its | |
| 138 | * own inputs the position in it is already the result of. | |
| 139 | */ | |
| 140 | applied: Map<number, number>; | |
| 136 | 141 | /** The buy each seat has already been charged for, so one ask is one buy. */ |
| 137 | 142 | bought: Map<number, string>; |
| 138 | 143 | /** The world clock of the newest feed entry and line already sent out. */ |
| @@ -149,8 +154,7 @@ export type Room = { | ||
| 149 | 154 | }; |
| 150 | 155 | |
| 151 | 156 | /** The level a room stands on: de_forge, or the one that map name carves. */ |
| 152 | export const levelOf = (map: PlayedMap): Level => | |
| 153 | map === "de_forge" ? DE_FORGE : generateLevel(seedOf(map) ?? 0); | |
| 157 | export const levelOf = (map: PlayedMap): Level => levelNamed(map); | |
| 154 | 158 | |
| 155 | 159 | /** |
| 156 | 160 | * A room on a map, with ten bots in it. The world is the shooter's own and so is |
| @@ -180,6 +184,7 @@ export function createRoom( | ||
| 180 | 184 | tick: 0, |
| 181 | 185 | members: new Map(), |
| 182 | 186 | pressed: new Map(), |
| 187 | applied: new Map(), | |
| 183 | 188 | botNames: world.players.map((p) => p.name), |
| 184 | 189 | bought: new Map(), |
| 185 | 190 | loggedFeed: 0, |
| @@ -294,6 +299,7 @@ export function leave(room: Room, id: string, now: number): Member | null { | ||
| 294 | 299 | } |
| 295 | 300 | room.members.delete(id); |
| 296 | 301 | room.pressed.delete(member.seat); |
| 302 | room.applied.delete(member.seat); | |
| 297 | 303 | room.bought.delete(member.seat); |
| 298 | 304 | room.usedAt = now; |
| 299 | 305 | return member; |
| @@ -340,6 +346,7 @@ function driveSeat(room: Room, seat: number, p: Player): Input | undefined { | ||
| 340 | 346 | // keeps running: see `INPUT_HOLD`. |
| 341 | 347 | if (room.tick - held.tick > INPUT_HOLD) return { ...NO_INPUT }; |
| 342 | 348 | const { pressed } = held; |
| 349 | room.applied.set(seat, pressed.tick); | |
| 343 | 350 | p.yaw = pressed.yaw; |
| 344 | 351 | p.pitch = pressed.pitch; |
| 345 | 352 | // One ask is one buy: the frame goes up every tick while the key is held, and |
| @@ -373,7 +380,7 @@ export function stepRoom(room: Room): void { | ||
| 373 | 380 | * because the buffer is one room's, written through each time. |
| 374 | 381 | */ |
| 375 | 382 | export function snapshotFor(room: Room, member: Member): Uint8Array { |
| 376 | const bytes = writeSnapshot(room.world, room.tick, member.sentAt, room.frame); | |
| 383 | const bytes = writeSnapshot(room.world, room.tick, member.sentAt, room.frame, room.applied.get(member.seat) ?? 0); | |
| 377 | 384 | member.sentAt = room.world.time; |
| 378 | 385 | return bytes; |
| 379 | 386 | } |
src/rooms/wire.test.ts +13
| @@ -180,6 +180,19 @@ test("a snapshot that is not one changes nothing", () => { | ||
| 180 | 180 | expect([...(mine.players[1] as Player).pos]).toEqual(was); |
| 181 | 181 | }); |
| 182 | 182 | |
| 183 | test("a snapshot ends with the input it answers, and one without it answers nothing", () => { | |
| 184 | const r = room(); | |
| 185 | const mine = watcher(r); | |
| 186 | const plain = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX)).slice(); | |
| 187 | const acked = writeSnapshot(r.world, r.tick, 0, new Uint8Array(SNAPSHOT_MAX), 4242).slice(); | |
| 188 | // Four bytes on the end and not one byte anywhere else: a page on the | |
| 189 | // previous deploy reads the same frame it always read. | |
| 190 | expect(acked.byteLength).toBe(plain.byteLength + 4); | |
| 191 | expect([...acked.subarray(0, plain.byteLength)]).toEqual([...plain]); | |
| 192 | expect(applySnapshot(mine, acked, 0)?.ack).toBe(4242); | |
| 193 | expect(applySnapshot(mine, plain, 0)?.ack).toBe(null); | |
| 194 | }); | |
| 195 | ||
| 183 | 196 | test("a control message off the wire is validated field by field", () => { |
| 184 | 197 | const hello = readDown(JSON.stringify({ |
| 185 | 198 | k: "hello", code: "ABCDEF", seat: 3, map: "de_forge", seed: 9, rules: "short", tick: 10, |
src/rooms/wire.ts +27 −3
| @@ -197,7 +197,21 @@ const EFFECT_KINDS = ["tracer", "impact", "blood", "shot", "blast", "sound"] as | ||
| 197 | 197 | * accept: `src/client/session.ts` predicted a position for this tick already and |
| 198 | 198 | * reconciles against this one. |
| 199 | 199 | */ |
| 200 | export type Snapshot = { tick: number; time: number; events: number; own: { pos: Vec3; vel: Vec3 } | null }; | |
| 200 | export type Snapshot = { | |
| 201 | tick: number; | |
| 202 | time: number; | |
| 203 | events: number; | |
| 204 | own: { pos: Vec3; vel: Vec3 } | null; | |
| 205 | /** | |
| 206 | * The stamp of the newest input of this member's the room had applied to | |
| 207 | * their body when it took this snapshot, or null for a frame that does not | |
| 208 | * carry one. It is the one number prediction needs that the rest of the | |
| 209 | * frame cannot give it: `tick` above is the *room's* step count and an input | |
| 210 | * is stamped with the *member's* own count of frames sent, and the two | |
| 211 | * clocks never meet. See the trailer in `writeSnapshot`. | |
| 212 | */ | |
| 213 | ack: number | null; | |
| 214 | }; | |
| 201 | 215 | |
| 202 | 216 | /** |
| 203 | 217 | * One room's round, whole, into `into`. Everything in `world` that a screen or |
| @@ -210,7 +224,9 @@ export type Snapshot = { tick: number; time: number; events: number; own: { pos: | ||
| 210 | 224 | * has no hit marker and no damage arrow yet, which is stated on the page and in |
| 211 | 225 | * the README rather than faked. |
| 212 | 226 | */ |
| 213 | export function writeSnapshot(world: World, tick: number, since: number, into: Uint8Array): Uint8Array { | |
| 227 | export function writeSnapshot( | |
| 228 | world: World, tick: number, since: number, into: Uint8Array, ack: number | null = null, | |
| 229 | ): Uint8Array { | |
| 214 | 230 | const c = new Cursor(into); |
| 215 | 231 | c.u8(SNAPSHOT_FRAME); |
| 216 | 232 | c.u32(tick); |
| @@ -256,6 +272,12 @@ export function writeSnapshot(world: World, tick: number, since: number, into: U | ||
| 256 | 272 | const events = eventsSince(world, since); |
| 257 | 273 | c.u8(events.length); |
| 258 | 274 | for (const e of events) writeEvent(c, e); |
| 275 | // The trailer: which of the member's own inputs this is the result of (see | |
| 276 | // `Snapshot.ack`). It is last, after everything else, so a page still open on | |
| 277 | // the deploy before it reads the frame it always read and never looks here — | |
| 278 | // and a frame without it is read as one that carries no acknowledgement, | |
| 279 | // which is a page that simply does not predict. | |
| 280 | if (ack !== null) c.u32(ack); | |
| 259 | 281 | return into.subarray(0, c.at); |
| 260 | 282 | } |
| 261 | 283 | |
| @@ -435,7 +457,9 @@ export function applySnapshot(world: World, bytes: Uint8Array, seat: number): Sn | ||
| 435 | 457 | pushEffect(world.effects, effect, effect.at); |
| 436 | 458 | read += 1; |
| 437 | 459 | } |
| 438 | return { tick, time, events: read, own }; | |
| 460 | // Only a frame read to the end of its events is aligned on the trailer. | |
| 461 | const ack = read === events && c.has(4) ? c.ru32() : null; | |
| 462 | return { tick, time, events: read, own, ack }; | |
| 439 | 463 | } |
| 440 | 464 | |
| 441 | 465 | let mine: { pos: Vec3; vel: Vec3 } | null = null; |
new above
offline