fable/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Send every seat every tick: the third seat no longer halves the rate
The cadence used to drop to every other tick at the third seat, and the client's render delay follows the rate it measures, so that seat moved every other body everybody saw from 45ms in the past to 87ms. What it saved is now measured and written on the rule: 6µs a snapshot, 2ms of CPU a second at ten seats, 465 kB/s out of the box in a live round. The rule is the game's now — `broadcastEvery` in src/arena.ts, every tick for every seat this match can hold — and the engine only gained an optional `cadence` on its game contract, defaulting to what it had. The val game is a factory (`valGame`, `makeValArena`) so a test can seat a room of its own, and the feel harness measures a room of four and of eight at both cadences. No client change. Co-Authored-By: Claude Fable 5.1 <[email protected]>
6 files
changed +431
−204
README.md +3 −2
| @@ -96,8 +96,9 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator. | ||
| 96 | 96 | body drawn slow, then fast. The moment being drawn advances at real time |
| 97 | 97 | and creeps toward its delay at no more than two percent of it, so the |
| 98 | 98 | delay adapting never shows as a short frame. Snapshots go out every tick |
| 99 | (30Hz, some 2 KiB a seat) while two or fewer seats are filled, and every | |
| 100 | other tick above that. The scoreboard — ten names, agents and tallies, | |
| 99 | (30Hz, some 1.6 KiB a seat in a live round) to every seat the room can | |
| 100 | hold; the cadence is a function of the room in `src/arena.ts`, with the | |
| 101 | measured cost in its comment. The scoreboard — ten names, agents and tallies, | |
| 101 | 102 | almost half of every snapshot — is sent only when it differs from what |
| 102 | 103 | that seat last received, and the page keeps the one it has; everything |
| 103 | 104 | else is in every snapshot. |
src/arena.test.ts +15 −13
| @@ -14,12 +14,13 @@ import { join } from "node:path"; | ||
| 14 | 14 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); |
| 15 | 15 | const { advance, attach, broadcastEvery, match, release, seatCount, snapshot } = await import("./arena.ts"); |
| 16 | 16 | |
| 17 | test("a small room hears every tick, a full one every other", () => { | |
| 18 | expect(broadcastEvery(0)).toBe(1); | |
| 19 | expect(broadcastEvery(1)).toBe(1); | |
| 20 | expect(broadcastEvery(2)).toBe(1); | |
| 21 | expect(broadcastEvery(3)).toBe(2); | |
| 22 | expect(broadcastEvery(10)).toBe(2); | |
| 17 | test("every room this match can hold hears every tick", () => { | |
| 18 | // The third seat used to halve the rate, which drew every other body 42ms | |
| 19 | // further in the past for everyone. What it saved is in the comment on the | |
| 20 | // rule; the ten-seat room is the whole of this game. | |
| 21 | for (let filled = 0; filled <= 10; filled++) expect(broadcastEvery(filled)).toBe(1); | |
| 22 | // Past the measured room the old cadence stays, unmeasured rather than right. | |
| 23 | expect(broadcastEvery(11)).toBe(2); | |
| 23 | 24 | }); |
| 24 | 25 | |
| 25 | 26 | /** A seat that only counts what it was sent. */ |
| @@ -44,21 +45,22 @@ test("the loop sends at the cadence the seat count asks for", () => { | ||
| 44 | 45 | release("rate-b", "rate-b"); |
| 45 | 46 | }); |
| 46 | 47 | |
| 47 | test("a full room halves the cadence rather than multiplying the traffic", () => { | |
| 48 | test("a third seat no longer halves what everybody is sent", () => { | |
| 48 | 49 | const seats = ["rate-c", "rate-d", "rate-e"].map(listen); |
| 49 | 50 | expect(seatCount()).toBeGreaterThan(2); |
| 50 | 51 | advance(1); |
| 51 | for (const got of seats) expect(got.length).toBe(15); | |
| 52 | for (const got of seats) expect(got.length).toBe(30); | |
| 52 | 53 | for (const session of ["rate-c", "rate-d", "rate-e"]) release(session, session); |
| 53 | 54 | }); |
| 54 | 55 | |
| 55 | 56 | test("a snapshot stays small enough to send thirty times a second", () => { |
| 56 | 57 | // The trade is only worth making if the payload is small. Measured over a |
| 57 | // live round it is 3.2-4.5 KiB a seat, mean 3.4 — a hundred KiB a second | |
| 58 | // at 30Hz, and at most two seats are sent that much. This is the number | |
| 59 | // the pull quotes, so it is checked here rather than assumed; the ceiling | |
| 60 | // is loose enough not to fail on a busy tick and tight enough that a | |
| 61 | // snapshot which doubled in size would say so. | |
| 58 | // live round it is ~1.6 KiB a seat since the board went sticky and the | |
| 59 | // vision filter came in — 48 KiB a second at 30Hz, and every seat is sent | |
| 60 | // that much now. This is the number the cadence's comment quotes, so it | |
| 61 | // is checked here rather than assumed; the ceiling is loose enough not to | |
| 62 | // fail on a busy tick and tight enough that a snapshot which doubled in | |
| 63 | // size would say so. | |
| 62 | 64 | for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1); |
| 63 | 65 | const bytes = Buffer.byteLength(snapshot(match().players[0]!.id)); |
| 64 | 66 | expect(bytes).toBeGreaterThan(200); |
src/arena.ts +234 −181
| @@ -14,22 +14,51 @@ | ||
| 14 | 14 | import { agentById, sightBlocked } from "./abilities.ts"; |
| 15 | 15 | import { runBots } from "./bots.ts"; |
| 16 | 16 | import { recordResult } from "./db.ts"; |
| 17 | import { makeArena, type Command, type Why } from "./engine/arena.ts"; | |
| 17 | import { makeArena, type Command, type Game, 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 | 20 | import { wallBetween } from "./map.ts"; |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 21 | import { buyAbility, buyShield, buyWeapon, newMatch, pickAgent, PLAYER_R, ROSTER, setInput, speedOf, tick, visible } from "./match.ts"; | |
| 22 | 22 | import type { MatchState, Player, Zone } from "./state.ts"; |
| 23 | 23 | import { isWeaponId, WEAPONS } from "./weapons.ts"; |
| 24 | 24 | |
| 25 | export { broadcastEvery, TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; | |
| 25 | export { TICK, type Command, type Seat, type Why } from "./engine/arena.ts"; | |
| 26 | 26 | |
| 27 | const state: MatchState = newMatch(); | |
| 28 | let lastResult: string | null = null; | |
| 29 | // The kill feed is read off the match a tick at a time; the rules keep no | |
| 30 | // list of their own, and this is a view, not a rule. | |
| 31 | let marks: Mark[] = marksOf(state.players); | |
| 32 | let feed: Fall[] = []; | |
| 27 | /** | |
| 28 | * How many ticks apart this game's snapshots go out, given the seats filled: | |
| 29 | * every tick, for every room this match can hold. | |
| 30 | * | |
| 31 | * The rule used to halve the rate at the third seat. The browser's render | |
| 32 | * delay follows the rate it measures — 1.3 × the interval, floored at 45ms | |
| 33 | * (`static/engine/interp.js`) — so that third seat moved every other body | |
| 34 | * everybody saw from 45ms in the past to 87ms. In a game about peeking a | |
| 35 | * corner first that was the wrong thing to save, and what it saved is small | |
| 36 | * enough to write down. | |
| 37 | * | |
| 38 | * Measured 2026-09 on a 6-vCPU EPYC 9555P under bun 1.3: medians of seven | |
| 39 | * 20-second runs of a seeded match with idle humans in the seats, the | |
| 40 | * never-sending loop subtracted to leave the snapshots alone. | |
| 41 | * | |
| 42 | * one world tick 27µs | |
| 43 | * one snapshot, vision filter and all 6µs | |
| 44 | * | |
| 45 | * seats loop, every tick of which snapshots bytes out: live round / whole match | |
| 46 | * 4 1.3ms a second 0.8ms 210 kB/s / 340 kB/s | |
| 47 | * 8 2.0ms a second 1.7ms 385 kB/s / 645 kB/s | |
| 48 | * 10 2.1ms a second 2.0ms 465 kB/s / 790 kB/s | |
| 49 | * | |
| 50 | * That is 0.2% of one core with the room full; a box five times slower is | |
| 51 | * at 1%. The wire is the larger number — 790 kB/s (6 Mbit/s) out of the box | |
| 52 | * at ten seats, 80 kB/s to each of them — which a small droplet's port | |
| 53 | * carries without noticing and a phone on 4G has ten times the headroom | |
| 54 | * for. A live-round snapshot is ~1.6 KiB a seat; a buy phase is larger, | |
| 55 | * which is the gap between the two bytes columns. | |
| 56 | * | |
| 57 | * The threshold is the room: ten seats is every seat there is, so the every | |
| 58 | * tick case is the only one a person ever sits in. Past the measured room | |
| 59 | * the old cadence stays because it is unmeasured, not because it is right. | |
| 60 | */ | |
| 61 | export const broadcastEvery = (filled: number): number => (filled <= ROSTER * 2 ? 1 : 2); | |
| 33 | 62 | |
| 34 | 63 | const round2 = (n: number): number => Math.round(n * 10) / 10; |
| 35 | 64 | |
| @@ -84,184 +113,208 @@ function runCommand(state: MatchState, player: Player, message: Command): void { | ||
| 84 | 113 | } |
| 85 | 114 | } |
| 86 | 115 | |
| 87 | // Names and a gun, never a position: the feed says what happened, not | |
| 88 | // where, so it can never be read as a call-out. | |
| 89 | const feedFor = (state: MatchState) => | |
| 90 | feed.map((f) => ({ | |
| 91 | k: f.killer, | |
| 92 | v: f.victim, | |
| 93 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 94 | h: f.headshot, | |
| 95 | t: round2(Math.max(0, state.now - f.at)), | |
| 96 | })); | |
| 116 | /** What this game is to the arena: one match, and the answers `makeArena` asks for. */ | |
| 117 | export function valGame( | |
| 118 | state: MatchState, | |
| 119 | record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult, | |
| 120 | cadence: (filled: number) => number = broadcastEvery, | |
| 121 | ): Game<MatchState, Player> { | |
| 122 | let lastResult: string | null = null; | |
| 123 | // The kill feed is read off the match a tick at a time; the rules keep no | |
| 124 | // list of their own, and this is a view, not a rule. | |
| 125 | let marks: Mark[] = marksOf(state.players); | |
| 126 | let feed: Fall[] = []; | |
| 127 | ||
| 128 | // Names and a gun, never a position: the feed says what happened, not | |
| 129 | // where, so it can never be read as a call-out. | |
| 130 | const feedFor = (state: MatchState) => | |
| 131 | feed.map((f) => ({ | |
| 132 | k: f.killer, | |
| 133 | v: f.victim, | |
| 134 | w: f.weapon ? WEAPONS[f.weapon].name : null, | |
| 135 | h: f.headshot, | |
| 136 | t: round2(Math.max(0, state.now - f.at)), | |
| 137 | })); | |
| 138 | ||
| 139 | /** The public state everyone already sees on the page itself. */ | |
| 140 | function spectatorShape(state: MatchState, _why: Why) { | |
| 141 | return { | |
| 142 | phase: state.phase, | |
| 143 | clock: round2(Math.max(0, state.clock)), | |
| 144 | round: state.round, | |
| 145 | score: state.score, | |
| 146 | attackers: state.attackers, | |
| 147 | result: state.result, | |
| 148 | last: state.last, | |
| 149 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 150 | feed: feedFor(state), | |
| 151 | }; | |
| 152 | } | |
| 153 | ||
| 154 | /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ | |
| 155 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | |
| 156 | const agent = agentById(me.agent); | |
| 157 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); | |
| 158 | // A zone is known to the viewer if they are standing in it (so your own | |
| 159 | // smoke never vanishes from under you) or, failing that, if no wall sits | |
| 160 | // between the two — the wall test only, not `sightBlocked`, which also | |
| 161 | // counts zones and would let a smoke hide the wall behind it. | |
| 162 | const canSeeZone = (z: Zone): boolean => | |
| 163 | Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); | |
| 164 | ||
| 165 | return { | |
| 166 | phase: state.phase, | |
| 167 | clock: round2(Math.max(0, state.clock)), | |
| 168 | round: state.round, | |
| 169 | score: state.score, | |
| 170 | attackers: state.attackers, | |
| 171 | result: state.result, | |
| 172 | last: state.last, | |
| 173 | me: { | |
| 174 | id: me.id, | |
| 175 | side: me.side, | |
| 176 | team: me.team, | |
| 177 | agent: me.agent, | |
| 178 | alive: me.alive, | |
| 179 | x: round2(me.x), | |
| 180 | y: round2(me.y), | |
| 181 | angle: round2(me.angle), | |
| 182 | hp: Math.round(me.hp), | |
| 183 | armour: Math.round(me.armour), | |
| 184 | credits: me.credits, | |
| 185 | weapon: me.weapon, | |
| 186 | ammo: me.ammo, | |
| 187 | reloading: me.reloadIn > 0, | |
| 188 | charges: me.charges, | |
| 189 | cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, | |
| 190 | ult: me.ult, | |
| 191 | ultNeeds: agent.abilities.x.points, | |
| 192 | blind: round2(me.blind), | |
| 193 | spike: me.spike, | |
| 194 | planting: round2(me.planting), | |
| 195 | // How fast the server is about to move you, in units a second, and not | |
| 196 | // the reasons behind it: the browser's prediction runs at this number | |
| 197 | // and re-derives nothing. A rule that holds you still — planting, | |
| 198 | // defusing, a decided round — arrives as a zero. To a tenth, like every | |
| 199 | // other number here: rounded to a whole unit instead, a hasted walk | |
| 200 | // (337.5) is drawn a half-unit a second fast and clipped back by the | |
| 201 | // cap every frame, which measured 0.2 points of per-frame spread. A | |
| 202 | // tenth is a no-op for feel and costs at most two bytes. | |
| 203 | speed: round2(speedOf(state, me)), | |
| 204 | bot: me.bot, | |
| 205 | }, | |
| 206 | players: seen.map((p) => ({ | |
| 207 | id: p.id, | |
| 208 | name: p.name, | |
| 209 | agent: p.agent, | |
| 210 | x: round2(p.x), | |
| 211 | y: round2(p.y), | |
| 212 | angle: round2(p.angle), | |
| 213 | hp: Math.round(p.hp), | |
| 214 | armour: Math.round(p.armour), | |
| 215 | alive: p.alive, | |
| 216 | mate: p.side === me.side, | |
| 217 | spike: p.spike, | |
| 218 | blind: p.blind > 0, | |
| 219 | revealed: p.revealed > 0, | |
| 220 | })), | |
| 221 | zones: state.zones | |
| 222 | .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) | |
| 223 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), | |
| 224 | spike: { | |
| 225 | planted: state.spike.planted, | |
| 226 | carried: state.spike.carried !== null, | |
| 227 | fuse: round2(state.spike.fuse), | |
| 228 | defuse: round2(state.spike.defuse), | |
| 229 | checkpoint: state.spike.checkpoint, | |
| 230 | // A loose spike is the attackers' business until it is armed. | |
| 231 | x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, | |
| 232 | y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, | |
| 233 | }, | |
| 234 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), | |
| 235 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), | |
| 236 | // The scoreboard — ten names, agents and tallies — was almost half of | |
| 237 | // every snapshot and changes a few times a round, so the arena sends it | |
| 238 | // only when it differs from what the seat last received. | |
| 239 | board: state.players.map((p) => ({ | |
| 240 | id: p.id, | |
| 241 | name: p.name, | |
| 242 | team: p.team, | |
| 243 | side: p.side, | |
| 244 | agent: p.agent, | |
| 245 | bot: p.bot, | |
| 246 | alive: p.alive, | |
| 247 | kills: p.kills, | |
| 248 | deaths: p.deaths, | |
| 249 | damage: Math.round(p.damageDone), | |
| 250 | credits: p.team === me.team ? p.credits : null, | |
| 251 | ult: p.ult, | |
| 252 | ultNeeds: agentById(p.agent).abilities.x.points, | |
| 253 | })), | |
| 254 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 255 | feed: feedFor(state), | |
| 256 | }; | |
| 257 | } | |
| 258 | ||
| 259 | /** | |
| 260 | * Who fell on this tick. A shot that landed this tick and started where the | |
| 261 | * killer stands is what tells a gun kill from an ability kill — the position | |
| 262 | * is used here and goes no further. | |
| 263 | */ | |
| 264 | function readFalls(state: MatchState): void { | |
| 265 | const after = marksOf(state.players); | |
| 266 | const shooters = new Set<string>(); | |
| 267 | for (const shot of state.shots) { | |
| 268 | if (!shot.hit || shot.age > 0) continue; | |
| 269 | for (const player of state.players) | |
| 270 | if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); | |
| 271 | } | |
| 272 | const falls = fallsBetween(marks, after, { | |
| 273 | at: state.now, | |
| 274 | events: state.events.slice(0, 6).map((e) => e.text), | |
| 275 | shooters, | |
| 276 | }); | |
| 277 | marks = after; | |
| 278 | if (falls.length) feed = [...feed, ...falls]; | |
| 279 | feed = trimFeed(feed, state.now); | |
| 280 | } | |
| 281 | ||
| 282 | /** One tick of this game: the bots, the rules, the feed, and the record. */ | |
| 283 | function step(state: MatchState, dt: number): void { | |
| 284 | runBots(state, dt); | |
| 285 | tick(state, dt); | |
| 286 | readFalls(state); | |
| 287 | if (state.result && state.result !== lastResult) { | |
| 288 | lastResult = state.result; | |
| 289 | record(state.result, state.score.red, state.score.blue, state.round); | |
| 290 | } | |
| 291 | if (state.result === null) lastResult = null; | |
| 292 | } | |
| 97 | 293 | |
| 98 | /** The public state everyone already sees on the page itself. */ | |
| 99 | function spectatorShape(state: MatchState, _why: Why) { | |
| 100 | 294 | return { |
| 101 | phase: state.phase, | |
| 102 | clock: round2(Math.max(0, state.clock)), | |
| 103 | round: state.round, | |
| 104 | score: state.score, | |
| 105 | attackers: state.attackers, | |
| 106 | result: state.result, | |
| 107 | last: state.last, | |
| 108 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 109 | feed: feedFor(state), | |
| 295 | state, | |
| 296 | players: (s) => s.players, | |
| 297 | step, | |
| 298 | sit, | |
| 299 | stand, | |
| 300 | visible, | |
| 301 | snapshot: snapshotShape, | |
| 302 | sticky: "board", | |
| 303 | cadence, | |
| 304 | spectatorSnapshot: spectatorShape, | |
| 305 | command: runCommand, | |
| 110 | 306 | }; |
| 111 | 307 | } |
| 112 | 308 | |
| 113 | /** The snapshot for one seat: `seen` is exactly who the arena says they may see. */ | |
| 114 | function snapshotShape(state: MatchState, me: Player, seen: Player[]) { | |
| 115 | const agent = agentById(me.agent); | |
| 116 | const canSee = (point: Point): boolean => !sightBlocked(me, point, state.zones); | |
| 117 | // A zone is known to the viewer if they are standing in it (so your own | |
| 118 | // smoke never vanishes from under you) or, failing that, if no wall sits | |
| 119 | // between the two — the wall test only, not `sightBlocked`, which also | |
| 120 | // counts zones and would let a smoke hide the wall behind it. | |
| 121 | const canSeeZone = (z: Zone): boolean => | |
| 122 | Math.hypot(me.x - z.x, me.y - z.y) <= z.r + PLAYER_R || !wallBetween(me, z); | |
| 309 | /** A served match of this game, on a fresh world unless one is handed over. */ | |
| 310 | export const makeValArena = ( | |
| 311 | state: MatchState = newMatch(), | |
| 312 | record: (summary: string, red: number, blue: number, rounds: number) => void = recordResult, | |
| 313 | cadence: (filled: number) => number = broadcastEvery, | |
| 314 | ) => makeArena<MatchState, Player>(valGame(state, record, cadence)); | |
| 123 | 315 | |
| 124 | return { | |
| 125 | phase: state.phase, | |
| 126 | clock: round2(Math.max(0, state.clock)), | |
| 127 | round: state.round, | |
| 128 | score: state.score, | |
| 129 | attackers: state.attackers, | |
| 130 | result: state.result, | |
| 131 | last: state.last, | |
| 132 | me: { | |
| 133 | id: me.id, | |
| 134 | side: me.side, | |
| 135 | team: me.team, | |
| 136 | agent: me.agent, | |
| 137 | alive: me.alive, | |
| 138 | x: round2(me.x), | |
| 139 | y: round2(me.y), | |
| 140 | angle: round2(me.angle), | |
| 141 | hp: Math.round(me.hp), | |
| 142 | armour: Math.round(me.armour), | |
| 143 | credits: me.credits, | |
| 144 | weapon: me.weapon, | |
| 145 | ammo: me.ammo, | |
| 146 | reloading: me.reloadIn > 0, | |
| 147 | charges: me.charges, | |
| 148 | cooldown: { c: round2(me.cooldown.c), q: round2(me.cooldown.q), e: round2(me.cooldown.e), x: 0 }, | |
| 149 | ult: me.ult, | |
| 150 | ultNeeds: agent.abilities.x.points, | |
| 151 | blind: round2(me.blind), | |
| 152 | spike: me.spike, | |
| 153 | planting: round2(me.planting), | |
| 154 | // How fast the server is about to move you, in units a second, and not | |
| 155 | // the reasons behind it: the browser's prediction runs at this number | |
| 156 | // and re-derives nothing. A rule that holds you still — planting, | |
| 157 | // defusing, a decided round — arrives as a zero. To a tenth, like every | |
| 158 | // other number here: rounded to a whole unit instead, a hasted walk | |
| 159 | // (337.5) is drawn a half-unit a second fast and clipped back by the | |
| 160 | // cap every frame, which measured 0.2 points of per-frame spread. A | |
| 161 | // tenth is a no-op for feel and costs at most two bytes. | |
| 162 | speed: round2(speedOf(state, me)), | |
| 163 | bot: me.bot, | |
| 164 | }, | |
| 165 | players: seen.map((p) => ({ | |
| 166 | id: p.id, | |
| 167 | name: p.name, | |
| 168 | agent: p.agent, | |
| 169 | x: round2(p.x), | |
| 170 | y: round2(p.y), | |
| 171 | angle: round2(p.angle), | |
| 172 | hp: Math.round(p.hp), | |
| 173 | armour: Math.round(p.armour), | |
| 174 | alive: p.alive, | |
| 175 | mate: p.side === me.side, | |
| 176 | spike: p.spike, | |
| 177 | blind: p.blind > 0, | |
| 178 | revealed: p.revealed > 0, | |
| 179 | })), | |
| 180 | zones: state.zones | |
| 181 | .filter((z) => (z.kind === "trip" ? z.side === me.side : canSeeZone(z))) | |
| 182 | .map((z) => ({ k: z.kind, x: round2(z.x), y: round2(z.y), r: z.r, f: round2(z.fuse), l: round2(z.life) })), | |
| 183 | spike: { | |
| 184 | planted: state.spike.planted, | |
| 185 | carried: state.spike.carried !== null, | |
| 186 | fuse: round2(state.spike.fuse), | |
| 187 | defuse: round2(state.spike.defuse), | |
| 188 | checkpoint: state.spike.checkpoint, | |
| 189 | // A loose spike is the attackers' business until it is armed. | |
| 190 | x: state.spike.planted || me.side === "atk" ? round2(state.spike.x) : null, | |
| 191 | y: state.spike.planted || me.side === "atk" ? round2(state.spike.y) : null, | |
| 192 | }, | |
| 193 | orbs: state.orbs.filter((o) => !o.taken).map((o) => ({ x: o.x, y: o.y })), | |
| 194 | shots: state.shots.filter((s) => canSee({ x: s.x1, y: s.y1 }) || canSee({ x: s.x2, y: s.y2 })), | |
| 195 | // The scoreboard — ten names, agents and tallies — was almost half of | |
| 196 | // every snapshot and changes a few times a round, so the arena sends it | |
| 197 | // only when it differs from what the seat last received. | |
| 198 | board: state.players.map((p) => ({ | |
| 199 | id: p.id, | |
| 200 | name: p.name, | |
| 201 | team: p.team, | |
| 202 | side: p.side, | |
| 203 | agent: p.agent, | |
| 204 | bot: p.bot, | |
| 205 | alive: p.alive, | |
| 206 | kills: p.kills, | |
| 207 | deaths: p.deaths, | |
| 208 | damage: Math.round(p.damageDone), | |
| 209 | credits: p.team === me.team ? p.credits : null, | |
| 210 | ult: p.ult, | |
| 211 | ultNeeds: agentById(p.agent).abilities.x.points, | |
| 212 | })), | |
| 213 | events: state.events.slice(0, 5).map((e) => e.text), | |
| 214 | feed: feedFor(state), | |
| 215 | }; | |
| 216 | } | |
| 217 | ||
| 218 | /** | |
| 219 | * Who fell on this tick. A shot that landed this tick and started where the | |
| 220 | * killer stands is what tells a gun kill from an ability kill — the position | |
| 221 | * is used here and goes no further. | |
| 222 | */ | |
| 223 | function readFalls(state: MatchState): void { | |
| 224 | const after = marksOf(state.players); | |
| 225 | const shooters = new Set<string>(); | |
| 226 | for (const shot of state.shots) { | |
| 227 | if (!shot.hit || shot.age > 0) continue; | |
| 228 | for (const player of state.players) | |
| 229 | if (Math.hypot(player.x - shot.x1, player.y - shot.y1) < 2) shooters.add(player.id); | |
| 230 | } | |
| 231 | const falls = fallsBetween(marks, after, { | |
| 232 | at: state.now, | |
| 233 | events: state.events.slice(0, 6).map((e) => e.text), | |
| 234 | shooters, | |
| 235 | }); | |
| 236 | marks = after; | |
| 237 | if (falls.length) feed = [...feed, ...falls]; | |
| 238 | feed = trimFeed(feed, state.now); | |
| 239 | } | |
| 240 | ||
| 241 | /** One tick of this game: the bots, the rules, the feed, and the record. */ | |
| 242 | function step(state: MatchState, dt: number): void { | |
| 243 | runBots(state, dt); | |
| 244 | tick(state, dt); | |
| 245 | readFalls(state); | |
| 246 | if (state.result && state.result !== lastResult) { | |
| 247 | lastResult = state.result; | |
| 248 | recordResult(state.result, state.score.red, state.score.blue, state.round); | |
| 249 | } | |
| 250 | if (state.result === null) lastResult = null; | |
| 251 | } | |
| 252 | ||
| 253 | const arena = makeArena<MatchState, Player>({ | |
| 254 | state, | |
| 255 | players: (s) => s.players, | |
| 256 | step, | |
| 257 | sit, | |
| 258 | stand, | |
| 259 | visible, | |
| 260 | snapshot: snapshotShape, | |
| 261 | sticky: "board", | |
| 262 | spectatorSnapshot: spectatorShape, | |
| 263 | command: runCommand, | |
| 264 | }); | |
| 316 | // The one live match the server serves. | |
| 317 | const arena = makeValArena(); | |
| 265 | 318 | |
| 266 | 319 | export const { |
| 267 | 320 | match, |
src/engine/arena.test.ts +19 −1
| @@ -12,7 +12,7 @@ type Toy = { id: string; name: string; bot: boolean; side: "a" | "b"; x: number; | ||
| 12 | 12 | type World = { ticks: number; players: Toy[]; heard: Command[] }; |
| 13 | 13 | |
| 14 | 14 | /** Two sides of two; everybody on side "b" is out of side "a"'s sight unless marked seen. */ |
| 15 | function toyGame() { | |
| 15 | function toyGame(cadence?: (filled: number) => number) { | |
| 16 | 16 | const world: World = { |
| 17 | 17 | ticks: 0, |
| 18 | 18 | heard: [], |
| @@ -40,6 +40,7 @@ function toyGame() { | ||
| 40 | 40 | board: s.players.map((p) => ({ id: p.id, name: p.name, bot: p.bot })), |
| 41 | 41 | }), |
| 42 | 42 | sticky: "board", |
| 43 | cadence, | |
| 43 | 44 | spectatorSnapshot: (s) => ({ ticks: s.ticks, names: s.players.map((p) => p.name) }), |
| 44 | 45 | command: (s, _p, message) => void s.heard.push(message), |
| 45 | 46 | }); |
| @@ -141,3 +142,20 @@ test("the loop sends at the cadence the room asks for, and a bot's commands are | ||
| 141 | 142 | arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside |
| 142 | 143 | expect(arena.match().heard.length).toBe(1); |
| 143 | 144 | }); |
| 145 | ||
| 146 | test("a game that names its own cadence is sent at it, whatever the default says", () => { | |
| 147 | // Where the rate halves is a fact about one game's snapshot and one game's | |
| 148 | // room, so the game says; the engine's default is only for a game that has | |
| 149 | // not measured. Three ticks apart here, to be sure it is this and not the default. | |
| 150 | const arena = toyGame((filled) => (filled >= 2 ? 3 : 1)); | |
| 151 | const got: string[] = []; | |
| 152 | const first = arena.takeSeat("10.0.0.1", "s1", "Kit")!; | |
| 153 | arena.attach("s1", first.id, "10.0.0.1", "t1", (data) => got.push(data)); | |
| 154 | arena.advance(1); | |
| 155 | expect(got.length).toBe(Math.round(1 / TICK)); | |
| 156 | const second = arena.takeSeat("10.0.0.2", "s2", "Kat")!; | |
| 157 | arena.attach("s2", second.id, "10.0.0.2", "t2", () => {}); | |
| 158 | got.length = 0; | |
| 159 | arena.advance(1); | |
| 160 | expect(got.length).toBe(Math.round(1 / TICK) / 3); | |
| 161 | }); |
src/engine/arena.ts +11 −7
| @@ -22,13 +22,13 @@ | ||
| 22 | 22 | export const TICK = 1 / 30; |
| 23 | 23 | |
| 24 | 24 | /** |
| 25 | * How many ticks apart the snapshots go out. | |
| 25 | * How many ticks apart the snapshots go out when the game does not say. | |
| 26 | 26 | * |
| 27 | * The simulation runs at 30Hz. A small room gets every tick: it is the case | |
| 28 | * that matters (one or two people playing each other), the payload is a | |
| 29 | * couple of kilobytes of JSON a seat, and the arena only runs at all while | |
| 30 | * somebody is connected. Above that the cost multiplies by seats and the | |
| 31 | * older cadence — every other tick — comes back. | |
| 27 | * The simulation runs at 30Hz. A small room gets every tick; above that the | |
| 28 | * cost multiplies by seats and the older cadence — every other tick — comes | |
| 29 | * back. Where the line sits is a fact about one game's snapshot and one | |
| 30 | * game's room, so a game that has measured its own passes `cadence` and this | |
| 31 | * default is never consulted. | |
| 32 | 32 | */ |
| 33 | 33 | export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2); |
| 34 | 34 | |
| @@ -73,6 +73,8 @@ export type Game<S, P extends Body> = { | ||
| 73 | 73 | * last received; the client keeps the one it has. Null sends everything. |
| 74 | 74 | */ |
| 75 | 75 | sticky: string | null; |
| 76 | /** How many ticks apart this game's snapshots go out, given the seats filled. `broadcastEvery` when unsaid. */ | |
| 77 | cadence?: (filled: number) => number; | |
| 76 | 78 | /** The public view for a connection with no seat. Must carry no position. */ |
| 77 | 79 | spectatorSnapshot: (state: S, why: Why) => Record<string, unknown>; |
| 78 | 80 | /** Everything a client may ask for, validated by the game and nowhere else. */ |
| @@ -258,10 +260,12 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) { | ||
| 258 | 260 | /** How many connections the arena is playing for: seats plus watchers. */ |
| 259 | 261 | const attendance = (): number => seats.size + spectators.size; |
| 260 | 262 | |
| 263 | const cadence = game.cadence ?? broadcastEvery; | |
| 264 | ||
| 261 | 265 | function step(): void { |
| 262 | 266 | game.step(state, TICK); |
| 263 | 267 | ticks += 1; |
| 264 | if (ticks % broadcastEvery(seats.size) !== 0) return; | |
| 268 | if (ticks % cadence(seats.size) !== 0) return; | |
| 265 | 269 | for (const seat of seats.values()) { |
| 266 | 270 | try { |
| 267 | 271 | seat.send(snapshotForSeat(seat)); |
src/feel.test.ts +149
| @@ -11,6 +11,9 @@ | ||
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { expect, test } from "bun:test"; |
| 14 | import { mkdtempSync } from "node:fs"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 14 | 17 | import { BODY_R, RUN } from "./engine/glide.ts"; |
| 15 | 18 | import { solidTile, TILE } from "./map.ts"; |
| 16 | 19 | import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts"; |
| @@ -19,6 +22,10 @@ import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static | ||
| 19 | 22 | import { makeLocal, stepLocal } from "../static/engine/predict.js"; |
| 20 | 23 | import { makeYaw, settle, told } from "../static/engine/yaw.js"; |
| 21 | 24 | |
| 25 | // The arena records finished matches under FORGE_APP_DATA the moment it is imported. | |
| 26 | process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); | |
| 27 | const { broadcastEvery, makeValArena } = await import("./arena.ts"); | |
| 28 | ||
| 22 | 29 | const round2 = (n: number) => Math.round(n * 10) / 10; |
| 23 | 30 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; |
| 24 | 31 | |
| @@ -459,3 +466,145 @@ test("the simulation's own motion is drawn at an even speed", async () => { | ||
| 459 | 466 | expect(mean * 60).toBeGreaterThan(150); |
| 460 | 467 | expect(spread).toBeLessThan(0.15); |
| 461 | 468 | }); |
| 469 | ||
| 470 | /** | |
| 471 | * Everybody else, as the real arena sends them to a real seat in a room of | |
| 472 | * `seats` people: the cadence the arena picks for that room, the vision | |
| 473 | * filter, the sticky board, all of it — over the same jittery wire as | |
| 474 | * `watchBots`, drawn the same way. The room is what is being measured, so | |
| 475 | * the seats sit idle and the bots do the walking. | |
| 476 | * | |
| 477 | * The wire opens three seconds before the round goes live and the first | |
| 478 | * three seconds of the rush are measured: the render delay creeps toward | |
| 479 | * its mark at two percent of real time, so a timeline that has just met a | |
| 480 | * 15Hz stream spends two seconds drawing bodies past the newest sample | |
| 481 | * while it gets there. That is what a room felt at the moment its third | |
| 482 | * seat filled; here each cadence is measured settled, and `cold` says how | |
| 483 | * many frames the buy phase spent guessing ahead. | |
| 484 | */ | |
| 485 | function watchRoom(seats: number, cadence: (filled: number) => number) { | |
| 486 | const random = rng(5); | |
| 487 | const arena = makeValArena(newMatch(23), () => {}, cadence); | |
| 488 | const packets: { at: number; bodies: Body[] }[] = []; | |
| 489 | let t = 0; | |
| 490 | let viewer = ""; | |
| 491 | for (let i = 0; i < seats; i++) { | |
| 492 | const seat = arena.takeSeat(`10.9.0.${i + 1}`, `feel-${i}`, `Seat ${i}`)!; | |
| 493 | if (i === 0) viewer = seat.id; | |
| 494 | arena.attach(`feel-${i}`, seat.id, `10.9.0.${i + 1}`, `t${i}`, (data) => { | |
| 495 | if (i !== 0) return; | |
| 496 | const snap = JSON.parse(data) as { players: Body[] }; | |
| 497 | packets.push({ at: t + 0.02 + jitter(random), bodies: snap.players.filter((p) => p.id !== viewer) }); | |
| 498 | }); | |
| 499 | } | |
| 500 | const tickOnce = () => { | |
| 501 | t += 1 / 30; | |
| 502 | arena.advance(1 / 30); | |
| 503 | }; | |
| 504 | // The last three seconds of the buy phase, then the first three of the round. | |
| 505 | const state = arena.match(); | |
| 506 | while (!(state.phase === "buy" && state.clock <= 3)) tickOnce(); | |
| 507 | packets.length = 0; | |
| 508 | t = 0; | |
| 509 | let warm = 0; | |
| 510 | while (state.phase !== "live") { | |
| 511 | tickOnce(); | |
| 512 | warm = t; | |
| 513 | } | |
| 514 | for (let k = 0; k < 90; k++) tickOnce(); | |
| 515 | packets.sort((a, b) => a.at - b.at); | |
| 516 | ||
| 517 | const timeline = makeTimeline(); | |
| 518 | const paths = new Map<string, { x: number; y: number }[]>(); | |
| 519 | const delays: number[] = []; | |
| 520 | let ahead = 0; | |
| 521 | let cold = 0; | |
| 522 | let frames = 0; | |
| 523 | let next = 0; | |
| 524 | let restartsWarm = 0; | |
| 525 | for (let f = 0; f / 60 < warm + 2.8; f++) { | |
| 526 | const now = f / 60; | |
| 527 | while (next < packets.length && packets[next]!.at <= now) { | |
| 528 | const stamp = arrive(timeline, packets[next]!.at); | |
| 529 | for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); | |
| 530 | next += 1; | |
| 531 | } | |
| 532 | if (next < 4) continue; | |
| 533 | const drawAt = renderTime(timeline, now); | |
| 534 | if (now < warm) { | |
| 535 | if (drawAt > timeline.stamp) cold += 1; | |
| 536 | restartsWarm = timeline.restarts; | |
| 537 | continue; | |
| 538 | } | |
| 539 | delays.push(now - drawAt); | |
| 540 | frames += 1; | |
| 541 | if (drawAt > timeline.stamp) ahead += 1; | |
| 542 | for (const p of packets[next - 1]!.bodies) { | |
| 543 | const at = bodyAt(timeline, p, drawAt); | |
| 544 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); | |
| 545 | } | |
| 546 | } | |
| 547 | const walked = [...paths.values()] | |
| 548 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) | |
| 549 | .map((steps) => steps.filter((s) => s > 0.001)) | |
| 550 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; | |
| 551 | const { mean, spread } = spreadOf(walked); | |
| 552 | return { | |
| 553 | packets: packets.filter((p) => p.at >= warm).length, | |
| 554 | steps: walked.length, | |
| 555 | mean, | |
| 556 | spread, | |
| 557 | restarts: timeline.restarts - restartsWarm, | |
| 558 | delay: { min: Math.min(...delays), max: Math.max(...delays), now: delay(timeline) }, | |
| 559 | ahead, | |
| 560 | cold, | |
| 561 | frames, | |
| 562 | }; | |
| 563 | } | |
| 564 | ||
| 565 | /** The rule as it stood before: every other tick from the third seat. */ | |
| 566 | const halvedAtThree = (filled: number): number => (filled <= 2 ? 1 : 2); | |
| 567 | ||
| 568 | test("in a room of four or eight, everybody else is drawn 45ms behind, not 87, with no new jitter", () => { | |
| 569 | for (const seats of [4, 8]) { | |
| 570 | const before = watchRoom(seats, halvedAtThree); | |
| 571 | const after = watchRoom(seats, broadcastEvery); | |
| 572 | for (const [label, r] of [ | |
| 573 | ["before", before], | |
| 574 | [" after", after], | |
| 575 | ] as const) { | |
| 576 | console.log( | |
| 577 | `room of ${seats}, ${label}: ${r.packets} snapshots in 3s, ${r.steps} steps, mean ${r.mean.toFixed(2)}/frame, ` + | |
| 578 | `spread ${(r.spread * 100).toFixed(1)}%, restarts ${r.restarts}, ` + | |
| 579 | `delay ${(r.delay.min * 1000).toFixed(1)}–${(r.delay.max * 1000).toFixed(1)}ms (${(r.delay.now * 1000).toFixed(1)} in force), ` + | |
| 580 | `drawn past the newest sample ${r.ahead}/${r.frames} frames, ${r.cold} while the delay settled`, | |
| 581 | ); | |
| 582 | } | |
| 583 | // Thirty a second reach this seat, not fifteen. | |
| 584 | expect(after.packets).toBeGreaterThanOrEqual(85); | |
| 585 | expect(before.packets).toBeLessThanOrEqual(46); | |
| 586 | // And so every other body is drawn at the floor of the render delay, not | |
| 587 | // 1.3 intervals of a halved rate: 45ms behind the present, not 87. | |
| 588 | expect(after.delay.max).toBeLessThan(0.05); | |
| 589 | expect(before.delay.max).toBeGreaterThan(0.08); | |
| 590 | // Closer to the present buys no jitter: the steps are never less even | |
| 591 | // than they were at the halved rate, and no body in sight all along | |
| 592 | // started again. In the room of four the body measured runs clean and | |
| 593 | // holds the small room's 8% (`watchBots`); in the room of eight the two | |
| 594 | // bots left thread past the idle bodies at spawn, and those stops are the | |
| 595 | // server's, the same in both columns, so only the comparison is held. | |
| 596 | expect(after.steps).toBeGreaterThan(40); | |
| 597 | expect(after.mean * 60).toBeGreaterThan(150); | |
| 598 | if (seats === 4) expect(after.spread).toBeLessThan(0.08); | |
| 599 | expect(after.spread).toBeLessThanOrEqual(before.spread + 0.01); | |
| 600 | expect(after.restarts).toBeLessThanOrEqual(before.restarts); | |
| 601 | // A 45ms delay over a 33ms beat leaves 12ms for a packet to be late, and | |
| 602 | // the wire holds one in thirty by about that: the odd frame is drawn on | |
| 603 | // the newest pair's velocity, one interval at most, which a room of two | |
| 604 | // gets today. What must not appear is the run of guessed frames a | |
| 605 | // timeline spends settling on a halved rate. | |
| 606 | expect(after.ahead).toBeLessThanOrEqual(Math.ceil(after.frames * 0.03)); | |
| 607 | expect(before.cold).toBeGreaterThan(10); | |
| 608 | expect(after.cold).toBeLessThanOrEqual(2); | |
| 609 | } | |
| 610 | }); |