rook/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine

Merge pull request #31 from fable/29-above-two-players-every-enemy-is-drawn-4

Above two players, every enemy is drawn 42ms further in the past than it needs to be
benjivers committed fc929c5 merge Browse files
14 files changed +481 −228
README.md +3 −2
@@ -96,8 +96,9 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
9696 body drawn slow, then fast. The moment being drawn advances at real time
9797 and creeps toward its delay at no more than two percent of it, so the
9898 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,
101102 almost half of every snapshot — is sent only when it differs from what
102103 that seat last received, and the page keeps the one it has; everything
103104 else is in every snapshot.
src/arena.test.ts +15 −13
@@ -14,12 +14,13 @@ import { join } from "node:path";
1414 process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
1515 const { advance, attach, broadcastEvery, match, release, seatCount, snapshot } = await import("./arena.ts");
1616
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);
2324 });
2425
2526 /** 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", () => {
4445 release("rate-b", "rate-b");
4546 });
4647
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", () => {
4849 const seats = ["rate-c", "rate-d", "rate-e"].map(listen);
4950 expect(seatCount()).toBeGreaterThan(2);
5051 advance(1);
51 for (const got of seats) expect(got.length).toBe(15);
52 for (const got of seats) expect(got.length).toBe(30);
5253 for (const session of ["rate-c", "rate-d", "rate-e"]) release(session, session);
5354 });
5455
5556 test("a snapshot stays small enough to send thirty times a second", () => {
5657 // 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.
6264 for (let i = 0; i < 40 && match().phase !== "live"; i++) advance(1);
6365 const bytes = Buffer.byteLength(snapshot(match().players[0]!.id));
6466 expect(bytes).toBeGreaterThan(200);
src/arena.ts +234 −181
@@ -14,22 +14,51 @@
1414 import { agentById, sightBlocked } from "./abilities.ts";
1515 import { runBots } from "./bots.ts";
1616 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";
1818 import { fallsBetween, marksOf, trimFeed, type Fall, type Mark } from "./killfeed.ts";
1919 import type { Point } from "./map.ts";
2020 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";
2222 import type { MatchState, Player, Zone } from "./state.ts";
2323 import { isWeaponId, WEAPONS } from "./weapons.ts";
2424
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";
2626
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);
3362
3463 const round2 = (n: number): number => Math.round(n * 10) / 10;
3564
@@ -84,184 +113,208 @@ function runCommand(state: MatchState, player: Player, message: Command): void {
84113 }
85114 }
86115
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 }
97293
98 /** The public state everyone already sees on the page itself. */
99 function spectatorShape(state: MatchState, _why: Why) {
100294 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,
110306 };
111307 }
112308
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));
123315
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();
265318
266319 export const {
267320 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;
1212 type World = { ticks: number; players: Toy[]; heard: Command[] };
1313
1414 /** 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) {
1616 const world: World = {
1717 ticks: 0,
1818 heard: [],
@@ -40,6 +40,7 @@ function toyGame() {
4040 board: s.players.map((p) => ({ id: p.id, name: p.name, bot: p.bot })),
4141 }),
4242 sticky: "board",
43 cadence,
4344 spectatorSnapshot: (s) => ({ ticks: s.ticks, names: s.players.map((p) => p.name) }),
4445 command: (s, _p, message) => void s.heard.push(message),
4546 });
@@ -150,3 +151,20 @@ test("the loop sends at the cadence the room asks for, and a bot's commands are
150151 arena.command("b2", { t: "i" }); // a bot: nobody may drive it from outside
151152 expect(arena.match().heard.length).toBe(1);
152153 });
154
155 test("a game that names its own cadence is sent at it, whatever the default says", () => {
156 // Where the rate halves is a fact about one game's snapshot and one game's
157 // room, so the game says; the engine's default is only for a game that has
158 // not measured. Three ticks apart here, to be sure it is this and not the default.
159 const arena = toyGame((filled) => (filled >= 2 ? 3 : 1));
160 const got: string[] = [];
161 const first = arena.takeSeat("10.0.0.1", "s1", "Kit")!;
162 arena.attach("s1", first.id, "10.0.0.1", "t1", (data) => got.push(data));
163 arena.advance(1);
164 expect(got.length).toBe(Math.round(1 / TICK));
165 const second = arena.takeSeat("10.0.0.2", "s2", "Kat")!;
166 arena.attach("s2", second.id, "10.0.0.2", "t2", () => {});
167 got.length = 0;
168 arena.advance(1);
169 expect(got.length).toBe(Math.round(1 / TICK) / 3);
170 });
src/engine/arena.ts +11 −7
@@ -22,13 +22,13 @@
2222 export const TICK = 1 / 30;
2323
2424 /**
25 * How many ticks apart the snapshots go out.
25 * How many ticks apart the snapshots go out when the game does not say.
2626 *
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.
3232 */
3333 export const broadcastEvery = (filled: number): number => (filled <= 2 ? 1 : 2);
3434
@@ -73,6 +73,8 @@ export type Game<S, P extends Body> = {
7373 * last received; the client keeps the one it has. Null sends everything.
7474 */
7575 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;
7678 /** The public view for a connection with no seat. Must carry no position. */
7779 spectatorSnapshot: (state: S, why: Why) => Record<string, unknown>;
7880 /** Everything a client may ask for, validated by the game and nowhere else. */
@@ -261,10 +263,12 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
261263 /** How many connections the arena is playing for: seats plus watchers. */
262264 const attendance = (): number => seats.size + spectators.size;
263265
266 const cadence = game.cadence ?? broadcastEvery;
267
264268 function step(): void {
265269 game.step(state, TICK);
266270 ticks += 1;
267 if (ticks % broadcastEvery(seats.size) !== 0) return;
271 if (ticks % cadence(seats.size) !== 0) return;
268272 for (const seat of seats.values()) {
269273 try {
270274 seat.send(snapshotForSeat(seat));
src/feel.test.ts +149
@@ -11,6 +11,9 @@
1111 */
1212
1313 import { expect, test } from "bun:test";
14 import { mkdtempSync } from "node:fs";
15 import { tmpdir } from "node:os";
16 import { join } from "node:path";
1417 import { BODY_R, RUN } from "./engine/glide.ts";
1518 import { solidTile, TILE } from "./map.ts";
1619 import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts";
@@ -19,6 +22,10 @@ import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static
1922 import { makeLocal, stepLocal } from "../static/engine/predict.js";
2023 import { makeYaw, settle, told } from "../static/engine/yaw.js";
2124
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
2229 const round2 = (n: number) => Math.round(n * 10) / 10;
2330 const world = { solid: solidTile, tile: TILE, r: BODY_R };
2431
@@ -459,3 +466,145 @@ test("the simulation's own motion is drawn at an even speed", async () => {
459466 expect(mean * 60).toBeGreaterThan(150);
460467 expect(spread).toBeLessThan(0.15);
461468 });
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 });
src/engine/aim.ts +5 −4
@@ -2,9 +2,10 @@
22 * How a bot points a gun.
33 *
44 * The shot itself is shared with people — weapon spread, the move penalty,
5 * the spray, the blind wobble all live in `weapons.ts` and apply to everyone
6 * the same way. What lives here is the part a person is bad at and a bot was
7 * not: seeing somebody, and getting the barrel onto them.
5 * the spray, the blind wobble all live in the game's own weapon rules and
6 * apply to everyone the same way. What lives here is the part a person is
7 * bad at and a bot was not: seeing somebody, and getting the barrel onto
8 * them.
89 *
910 * A hold on a target has three honest costs. It takes a moment to notice
1011 * (`reaction`), the barrel starts somewhere wrong and only settles the longer
@@ -44,7 +45,7 @@ export const REACTION_MAX = 0.35;
4445 export const FORGET = 0.4;
4546 /** Seconds for the cone to fall by 1/e while the same target is held. */
4647 export const SETTLE = 0.8;
47 /** The cone a bot settles to: under a Vandal's own spread, never zero. */
48 /** The cone a bot settles to: under the weapon's own spread, never zero. */
4849 export const CONE_FLOOR = 0.008;
4950 /** What the cone opens to the instant a target is acquired (~6°). */
5051 export const CONE_SWING = 0.105;
src/engine/arena.test.ts +10 −1
@@ -104,6 +104,15 @@ test("a viewer is sent only the bodies the game says they can see", () => {
104104 expect(JSON.stringify(snap.players)).not.toContain('"b1"');
105105 });
106106
107 test("an unknown viewer gets the spectator view, never another player's eyes", () => {
108 const arena = toyGame();
109 const snap = JSON.parse(arena.snapshot("nobody-here")) as Record<string, unknown>;
110 expect(snap).toMatchObject({ t: "s", spectator: true });
111 expect(snap.me).toBeUndefined();
112 expect(snap.players).toBeUndefined();
113 expect(JSON.stringify(snap)).not.toContain('"x"');
114 });
115
107116 test("a spectator is sent the public state and never a position", () => {
108117 const arena = toyGame();
109118 const got: string[] = [];
@@ -119,7 +128,7 @@ test("a spectator is sent the public state and never a position", () => {
119128
120129 test("the sticky field goes out once, then only when it changes", () => {
121130 const arena = toyGame();
122 const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, board: "" };
131 const seat = { playerId: "a1", token: "t", address: "10.0.0.1", send: () => {}, stickyCache: "" };
123132 const first = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
124133 expect(first.board?.length).toBe(4);
125134 const second = JSON.parse(arena.snapshotForSeat(seat)) as { board?: unknown[] };
src/engine/arena.ts +9 −6
@@ -38,7 +38,7 @@ export type Seat = {
3838 address: string;
3939 send: (data: string) => void;
4040 /** The sticky part of the snapshot as this seat last received it, so it is only resent when it changes. */
41 board: string;
41 stickyCache: string;
4242 };
4343
4444 /**
@@ -148,13 +148,13 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
148148 if (!seat) return null;
149149 seat.name = freeName(name, seat.id);
150150 addressSeats.set(address, session);
151 seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, board: "" });
151 seats.set(session, { playerId: seat.id, token: "", address, send: () => {}, stickyCache: "" });
152152 return seat;
153153 }
154154
155155 /** The socket that actually opened fills in how to reach it and its token. */
156156 function attach(session: string, playerId: string, address: string, token: string, send: (data: string) => void): void {
157 seats.set(session, { playerId, token, address, send, board: "" });
157 seats.set(session, { playerId, token, address, send, stickyCache: "" });
158158 }
159159
160160 const stillHeld = (playerId: string): boolean => [...seats.values()].some((s) => s.playerId === playerId);
@@ -218,8 +218,11 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
218218 // ----------------------------------------------------------- the snapshot
219219
220220 function snapshotFor(viewerId: string): Record<string, unknown> {
221 const me = findPlayer(viewerId);
222 // No seat, no eyes: an id that names nobody gets exactly what a seatless
223 // connection already gets, never another player's vision by accident.
224 if (!me) return { t: "s", spectator: true, why: "unsaid", ...game.spectatorSnapshot(state, "unsaid") };
221225 const players = game.players(state);
222 const me = findPlayer(viewerId) ?? players[0]!;
223226 const seen = players.filter((p) => game.visible(state, me, p));
224227 return { t: "s", ...game.snapshot(state, me, seen) };
225228 }
@@ -236,8 +239,8 @@ export function makeArena<S, P extends Body>(game: Game<S, P>) {
236239 const full = snapshotFor(seat.playerId);
237240 if (game.sticky !== null) {
238241 const part = JSON.stringify(full[game.sticky]);
239 if (part === seat.board) delete full[game.sticky];
240 else seat.board = part;
242 if (part === seat.stickyCache) delete full[game.sticky];
243 else seat.stickyCache = part;
241244 }
242245 return JSON.stringify(full);
243246 }
src/engine/boundary.test.ts +16 −5
@@ -9,12 +9,21 @@
99 */
1010
1111 import { expect, test } from "bun:test";
12 import { readdirSync, readFileSync } from "node:fs";
12 import { existsSync, readdirSync, readFileSync } from "node:fs";
1313 import { dirname, join, resolve } from "node:path";
1414
1515 const ROOT = resolve(import.meta.dir, "..", "..");
1616 const ENGINE = [join(ROOT, "src", "engine"), join(ROOT, "static", "engine")];
1717
18 /**
19 * The game's own nouns, read as data rather than imported as code — an
20 * import from here would itself be a crossing the test below refuses. A
21 * copy of the engine that lands in a fresh game starts with no such file
22 * and so no such guard, until that game writes one down.
23 */
24 const WORDS_FILE = join(ROOT, "src", "words.json");
25 const gameWords: string[] = existsSync(WORDS_FILE) ? JSON.parse(readFileSync(WORDS_FILE, "utf8")) : [];
26
1827 const inEngine = (path: string): boolean => ENGINE.some((dir) => resolve(path).startsWith(dir + "/"));
1928
2029 /** Every import specifier in a file: static, re-exported, or dynamic. */
@@ -70,10 +79,12 @@ test("no engine module, and no engine test, imports a game module", () => {
7079 expect(crossings).toEqual([]);
7180 });
7281
73 test("the engine does not know what a spike is", () => {
74 // The words of this game. An engine file that says one of them has a hole
75 // that should have been a parameter.
76 const words = /\b(spike|agent|credits|molly|defus\w*|plant\w*|orb|valorant)\b/i;
82 test.skipIf(gameWords.length === 0)("the engine does not know what this game calls things", () => {
83 // The words are the game's own, from src/words.json, never hardcoded here:
84 // a game that legitimately has a "spike" should not inherit a guard against
85 // a game it has never played. An engine file that says one of them has a
86 // hole that should have been a parameter.
87 const words = new RegExp(`\\b(${gameWords.join("|")})\\b`, "i");
7788 const said: string[] = [];
7889 for (const file of files) {
7990 if (file.endsWith(".test.ts")) continue;
src/engine/predict.test.ts +1 −1
@@ -55,7 +55,7 @@ test("a frame of movement is the speed it was given, in the direction it was giv
5555 const diagonal = anchor(makeLocal(), { x: 1200, y: 640 });
5656 advance(diagonal, 1, 1, 250, 1 / 50, world);
5757 expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6);
58 // Standing still, planted, or stopped to defuse: nothing moves.
58 // Standing still, or moving at a speed the game has reduced to zero: nothing moves.
5959 const still = anchor(makeLocal(), { x: 1200, y: 640 });
6060 advance(still, 0, 0, 250, 1 / 50, world);
6161 advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world);
src/server.test.ts +1 −1
@@ -267,7 +267,7 @@ test("the scoreboard is sent when it changes and left out when it has not", asyn
267267 const { snapshotForSeat } = await import("./arena.ts");
268268 const state = match();
269269 const player = state.players[0]!;
270 const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, board: "" };
270 const seat = { playerId: player.id, token: "t", address: "198.51.100.200", send: () => {}, stickyCache: "" };
271271 const first = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[]; last?: unknown };
272272 expect(first.board?.length).toBe(10);
273273 const second = JSON.parse(snapshotForSeat(seat)) as { board?: unknown[] };
src/words.json added +1
@@ -0,0 +1,1 @@
1 ["spike", "agent", "credits", "molly", "defus\\w*", "plant\\w*", "orb", "valorant"]
static/engine/predict.js +7 −6
@@ -162,12 +162,13 @@ export const insideWall = (local, world) => circleBlocked(local.x, local.y, worl
162162 * onto that, advanced a frame, and never drawn past it.
163163 *
164164 * A lean across a corner can put the drawn body inside a wall, and `refused`
165 * is the caller's word for anywhere else the server would not let you stand
166 * (the spawn pen in the buy phase). Both used to take the server's answer,
167 * which is a jump: pressed against a wall and strafing, that fired most
168 * frames and read as a stutter. Holding the last good place is invisible and
169 * just as honest — the next snapshot corrects it anyway. Anchoring is kept
170 * for the one case where holding still leaves the body in a wall.
165 * is the caller's word for anywhere else the server would not let you stand —
166 * a rule the game itself raises and drops, not a wall on the map. Both used
167 * to take the server's answer, which is a jump: pressed against a wall and
168 * strafing, that fired most frames and read as a stutter. Holding the last
169 * good place is invisible and just as honest — the next snapshot corrects it
170 * anyway. Anchoring is kept for the one case where holding still leaves the
171 * body in a wall.
171172 */
172173 export function stepLocal(local, me, held, speed, age, dt, world, refused) {
173174 const target = project(me, held.mx, held.my, speed, age, world);
↑↓ navigate ↵ open esc close

Keyboard shortcuts