/** * The whole feel, measured. * * A wire between the real match and the real client modules: snapshots leave * on the server's ticks, arrive a little late and a little unevenly, and the * browser side draws sixty frames a second from them exactly the way val.js * does. What comes out is a set of numbers a person's "it stutters" can be * held against — how even the per-frame steps are, how often a correction * jumps, how large the largest jump is — and every one of them is a rule * this file enforces from here on. */ import { expect, test } from "bun:test"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BODY_R, RUN } from "./engine/glide.ts"; import { solidTile, TILE } from "./map.ts"; import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts"; import { runBots } from "./bots.ts"; import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js"; import { makeLocal, stepLocal } from "../static/engine/predict.js"; import { makeYaw, settle, told } from "../static/engine/yaw.js"; // The arena records finished matches under FORGE_APP_DATA the moment it is imported. process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); const { broadcastEvery, makeValArena } = await import("./arena.ts"); const round2 = (n: number) => Math.round(n * 10) / 10; const world = { solid: solidTile, tile: TILE, r: BODY_R }; /** A seeded wobble, so the run is the same every time. */ function rng(seed: number) { let s = seed | 0; return () => { s = (s + 0x6d2b79f5) | 0; let t = Math.imul(s ^ (s >>> 15), s | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } /** * The wire as it was measured: 30 a second, p50 gap 33ms, p99 41–48ms, no * gap over 100ms. Each packet takes a base latency plus a wobble, and now * and then one is held a dozen milliseconds longer. */ function jitter(random: () => number): number { const u = random(); const v = random(); const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v); return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0); } const spreadOf = (steps: number[]) => { const mean = steps.reduce((s, x) => s + x, 0) / steps.length; const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length); return { mean, spread: sd / mean }; }; type Body = { id: string; x: number; y: number; angle: number }; type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string; speed: number }; bodies: Body[] }; /** * A person walking east across open ground for two seconds, sending intent * every 50ms, hearing back thirty times a second over a jittery wire, and * drawn at sixty frames — with the input reaching the server one way late. * * The packet carries `speed` the way the arena's snapshot does — the match's * own answer, to a tenth — and the drawing uses that and nothing else, which * is what val.js does. `on` is whatever the walk is carrying: a haste, an * operator, the states whose speed is not the plain run. */ function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) { const random = rng(7); const state = newMatch(11); const me = state.players[0]!; me.bot = false; for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) { runBots(state, 1 / 30); tick(state, 1 / 30); } // Somewhere open: the middle of the map, facing east. me.x = 1000; me.y = 640; me.hp = 100; me.alive = true; me.haste = 0; me.slow = 0; me.weapon = "classic"; Object.assign(me, on); const packets: Packet[] = []; const inputs: { at: number; mx: number; my: number }[] = []; const held = { mx: 0, my: 0 }; const walkFrom = 0.3; const walkUntil = 1.6; // The server's clock: one tick every 33ms; snapshots leave right after. for (let k = 0; k * (1 / 30) < 2.2; k++) { const t = k / 30; while (inputs.length && inputs[0]!.at <= t) { const input = inputs.shift()!; setInput(me, { mx: input.mx, my: input.my, angle: 0 }); } // The browser's send timer is on its own clock: every 50ms, arriving one // way of latency later. for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) { if (s * 0.05 < t - 1 / 30) continue; const sentAt = s * 0.05; const moving = sentAt >= walkFrom && sentAt < walkUntil; inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 }); } inputs.sort((a, b) => a.at - b.at); tick(state, 1 / 30); packets.push({ at: t + base + jitter(random), stamp: 0, me: { id: me.id, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), alive: me.alive, phase: state.phase, speed: round2(speedOf(state, me)), }, bodies: [], }); } packets.sort((a, b) => a.at - b.at); const timeline = makeTimeline(); const local = makeLocal(); let snap: Packet | null = null; let next = 0; let previous = 0; const steps: number[] = []; let back = 0; let anchors = 0; for (let f = 0; f / 60 < 2.2; f++) { const now = f / 60; while (next < packets.length && packets[next]!.at <= now) { snap = packets[next]!; snap.stamp = arrive(timeline, snap.at); next += 1; } if (!snap) continue; held.mx = now >= walkFrom && now < walkUntil ? 1 : 0; const before = local.anchored; // The speed the server sent, exactly as val.js passes it: no rule about // movement is re-derived on this side of the wire. stepLocal(local, snap.me, held, snap.me.speed, now - snap.stamp, 1 / 60, world, null); // The first word about where you are is taken outright: that one is not a jump. if (before > 0) anchors += local.anchored - before; if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) { const step = local.x - previous; steps.push(step); back = Math.min(back, step); } previous = local.x; } return { steps, back, anchors, local, server: me }; } test("your own body, walking, takes even steps and never a backward one", () => { const { steps, back, anchors } = walkOwnBody(); const { mean, spread } = spreadOf(steps); console.log( `own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + `largest backward step ${back.toFixed(2)}, anchors ${anchors}`, ); // Running east at 250 a second is 4.17 a frame. expect(mean * 60).toBeGreaterThan(RUN * 0.9); expect(mean * 60).toBeLessThan(RUN * 1.1); // Even: the per-frame step does not beat against the wire's jitter. expect(spread).toBeLessThan(0.05); // And a step backwards is a stutter by definition. expect(back).toBeGreaterThan(-0.01); expect(anchors).toBe(0); }); test("a hasted walk is drawn at the speed the wire carried, rounding and all", () => { // Haste is the case where the match's own answer is not a whole number: // 250 × 1.35 is 337.5, and the wire carries it to a tenth, like every other // number in the snapshot. What is checked is that the drawing runs at the // speed the server will use — no correction, no backward step, and no body // drawn out past where the server is about to put it. const { steps, back, anchors, local, server } = walkOwnBody(0.02, { haste: 10 }); const { mean, spread } = spreadOf(steps); console.log( `hasted body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + `largest backward step ${back.toFixed(2)}, anchors ${anchors}, drawn ${(local.x - server.x).toFixed(1)} past the server`, ); expect(mean * 60).toBeGreaterThan(RUN * 1.35 * 0.9); expect(mean * 60).toBeLessThan(RUN * 1.35 * 1.1); expect(spread).toBeLessThan(0.05); expect(back).toBeGreaterThan(-0.01); expect(anchors).toBe(0); }); test("starting and stopping never pulls you back, and the speed never dips a third", () => { // The first tenth of a second after the key goes down the server has not // heard yet, and the guess used to be yanked back to the server's still // body on each of the first two snapshots. Letting go, the server runs on // for the same lag and the guess must not be dragged back to meet it. const random = rng(3); const timeline = makeTimeline(); const local = makeLocal(); const me = { x: 1000, y: 640, angle: 0, alive: true }; const held = { mx: 0, my: 0 }; const hop = 0.03; // one way, plus the send timer's own phase let serverX = 1000; const packets: { at: number; x: number }[] = []; for (let k = 0; k / 30 < 2; k++) { const t = k / 30; // The server hears the key a hop after the next 50ms send. const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop; const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop; if (t > heardDown && t <= heardUp) serverX += RUN / 30; packets.push({ at: t + hop + jitter(random), x: round2(serverX) }); } let next = 0; let previous = 0; let back = 0; let slowest = Infinity; for (let f = 0; f / 60 < 2; f++) { const now = f / 60; while (next < packets.length && packets[next]!.at <= now) { me.x = packets[next]!.x; arrive(timeline, packets[next]!.at); next += 1; } if (next === 0) continue; held.mx = now >= 0.3 && now < 1.2 ? 1 : 0; stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null); const step = local.x - previous; if (now > 0.3 + 1 / 60) back = Math.min(back, step); if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step); previous = local.x; } console.log( `walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` + `settled ${(local.x - serverX).toFixed(1)} from the server`, ); expect(back).toBeGreaterThan(-0.01); expect(slowest).toBeGreaterThan((RUN / 60) * 0.67); // Where you stopped is where the server stops: the guess settles onto it. expect(Math.abs(local.x - serverX)).toBeLessThan(1); }); /** * Everybody else: a bot walking, heard about thirty times a second over the * same wire, drawn a little in the past between the samples on hand. */ function watchBots() { const random = rng(5); const state = newMatch(23); const step = () => { runBots(state, 1 / 30); tick(state, 1 / 30); }; for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); const packets: { at: number; bodies: Body[] }[] = []; for (let k = 0; k < 90; k++) { step(); packets.push({ at: k / 30 + 0.02 + jitter(random), bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), }); } const timeline = makeTimeline(); const paths = new Map(); const delays: number[] = []; let ahead = 0; let frames = 0; let next = 0; for (let f = 0; f / 60 < 2.8; f++) { const now = f / 60; while (next < packets.length && packets[next]!.at <= now) { const stamp = arrive(timeline, packets[next]!.at); for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); next += 1; } if (next < 4) continue; const drawAt = renderTime(timeline, now); delays.push(now - drawAt); frames += 1; if (drawAt > timeline.stamp) ahead += 1; for (const p of packets[next - 1]!.bodies) { const at = bodyAt(timeline, p, drawAt); paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); } } const walked = [...paths.values()] .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) .map((steps) => steps.filter((s) => s > 0.001)) .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline }; } test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => { const { walked, restarts, delays, ahead, frames } = watchBots(); const { mean, spread } = spreadOf(walked); const dmin = Math.min(...delays); const dmax = Math.max(...delays); console.log( `others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + `restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` + `drawn past the newest sample ${ahead}/${frames} frames`, ); expect(walked.length).toBeGreaterThan(40); expect(mean * 60).toBeGreaterThan(150); expect(spread).toBeLessThan(0.08); // A body that was in sight all along never jumps to start again. expect(restarts).toBe(0); // The moment drawn is never later than the newest word: no guessing ahead. expect(ahead).toBe(0); }); test("the render clock never steps backwards or stalls, whatever the wire does", () => { const random = rng(9); const timeline = makeTimeline(); let arrival = 0.02; let drawn = 0; let worstShort = Infinity; let worstLong = 0; for (let f = 0; f / 60 < 5; f++) { const now = f / 60; while (arrival <= now) { arrive(timeline, arrival); arrival += 1 / 30 + jitter(random) - 0.002; } const at = renderTime(timeline, now); if (f > 30 && drawn > 0) { worstShort = Math.min(worstShort, at - drawn); worstLong = Math.max(worstLong, at - drawn); } drawn = at; } console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`); // Within a few percent of real time, every frame: 16.67ms give or take. expect(worstShort * 60).toBeGreaterThan(0.97); expect(worstLong * 60).toBeLessThan(1.03); }); test("a burst of snapshots does not walk the even clock into the future", () => { // A tab back from the background is handed everything it missed at once. // The clock those samples are filed on may lead the arrivals by half a // beat and no more, or the sent angles age out and every echo of your own // view comes back as a correction. const timeline = makeTimeline(); let at = 10; for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); const before = timeline.stamp; for (let i = 0; i < 150; i++) { const stamp = arrive(timeline, (at += 0.0002)); expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9); expect(stamp).toBeGreaterThanOrEqual(before); } // And once the wire is itself again, the beat is back on it. for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01); }); test("a round start points you at the site once, and nothing stomps it a tick later", () => { const state = newMatch(31); const me = state.players[0]!; me.bot = false; const yaw = makeYaw(); let corrections = 0; let worstRound = 0; let at = 0; const hear = () => { const before = yaw.angle; settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at); if (yaw.angle !== before) corrections += 1; }; hear(); // A dozen round starts, each followed by a second of ticks with the client // echoing back exactly what it was pointed at, the way the input timer // does — and each echo reaching the server a tick after it was sent. const inFlight: number[] = []; for (let round = 0; round < 12; round++) { const were = corrections; startRound(state); hear(); // the snapshot that follows the tick which started the round for (let k = 0; k < 30; k++) { at += 1 / 30; if (inFlight.length) setInput(me, { angle: inFlight.shift()! }); if (k % 2 === 0) { told(yaw, yaw.angle, at); inFlight.push(yaw.angle); } tick(state, 1 / 30); hear(); } // Facing the site the server put us at, still, a second later. const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; const want = Math.atan2(centre.y - me.y, centre.x - me.x); expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11); worstRound = Math.max(worstRound, corrections - were); } console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`); // At most one a round — the spawn — and never a second one to angle zero. expect(worstRound).toBeLessThanOrEqual(1); }); /** * The match's own motion, sampled at the rate the server broadcasts it and * drawn at the rate a browser draws: what a bot walking across the map * actually looks like. The samples carry exactly what a snapshot carries — * a position rounded to a tenth, thirty times a second. * * The measure is the spread of the per-frame steps. A body drawn at the * server's speed takes even steps; the exponential chase this replaced took * a long step just after each snapshot and shorter ones after it, which is * choppiness with a number on it — twice this spread, measured the same way. */ test("the simulation's own motion is drawn at an even speed", async () => { const { runBots } = await import("./bots.ts"); const { newMatch, tick } = await import("./match.ts"); const round2 = (n: number) => Math.round(n * 10) / 10; // A match of its own: nothing here touches the arena everybody shares. const state = newMatch(); const step = () => { runBots(state, 1 / 30); tick(state, 1 / 30); }; for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = []; for (let i = 0; i < 60; i++) { step(); stream.push({ t: i / 30, bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), }); } const timeline = makeTimeline(); const paths = new Map(); let next = 0; for (let f = 0; f / 60 < 1.8; f++) { const now = f / 60; while (next < stream.length && stream[next]!.t <= now) { arrive(timeline, stream[next]!.t); for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t); next += 1; } for (const p of stream[Math.max(0, next - 1)]!.bodies) { const at = bodyAt(timeline, p, now - delay(timeline)); paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); } } // Whoever walked furthest in that second and a half. const walked = [...paths.values()] .filter((path) => path.length > 80) .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) .map((steps) => steps.filter((step) => step > 0.001)) .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; expect(walked.length).toBeGreaterThan(30); const mean = walked.reduce((s, x) => s + x, 0) / walked.length; const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean; // A body moving at the server's own speed: about four units every sixtieth // of a second, which is the run speed. expect(mean * 60).toBeGreaterThan(150); expect(spread).toBeLessThan(0.15); }); /** * Everybody else, as the real arena sends them to a real seat in a room of * `seats` people: the cadence the arena picks for that room, the vision * filter, the sticky board, all of it — over the same jittery wire as * `watchBots`, drawn the same way. The room is what is being measured, so * the seats sit idle and the bots do the walking. * * The wire opens three seconds before the round goes live and the first * three seconds of the rush are measured: the render delay creeps toward * its mark at two percent of real time, so a timeline that has just met a * 15Hz stream spends two seconds drawing bodies past the newest sample * while it gets there. That is what a room felt at the moment its third * seat filled; here each cadence is measured settled, and `cold` says how * many frames the buy phase spent guessing ahead. */ function watchRoom(seats: number, cadence: (filled: number) => number) { const random = rng(5); const arena = makeValArena(newMatch(23), () => {}, cadence); const packets: { at: number; bodies: Body[] }[] = []; let t = 0; let viewer = ""; for (let i = 0; i < seats; i++) { const seat = arena.takeSeat(`10.9.0.${i + 1}`, `feel-${i}`, `Seat ${i}`)!; if (i === 0) viewer = seat.id; arena.attach(`feel-${i}`, seat.id, `10.9.0.${i + 1}`, `t${i}`, (data) => { if (i !== 0) return; const snap = JSON.parse(data) as { players: Body[] }; packets.push({ at: t + 0.02 + jitter(random), bodies: snap.players.filter((p) => p.id !== viewer) }); }); } const tickOnce = () => { t += 1 / 30; arena.advance(1 / 30); }; // The last three seconds of the buy phase, then the first three of the round. const state = arena.match(); while (!(state.phase === "buy" && state.clock <= 3)) tickOnce(); packets.length = 0; t = 0; let warm = 0; while (state.phase !== "live") { tickOnce(); warm = t; } for (let k = 0; k < 90; k++) tickOnce(); packets.sort((a, b) => a.at - b.at); const timeline = makeTimeline(); const paths = new Map(); const delays: number[] = []; let ahead = 0; let cold = 0; let frames = 0; let next = 0; let restartsWarm = 0; for (let f = 0; f / 60 < warm + 2.8; f++) { const now = f / 60; while (next < packets.length && packets[next]!.at <= now) { const stamp = arrive(timeline, packets[next]!.at); for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); next += 1; } if (next < 4) continue; const drawAt = renderTime(timeline, now); if (now < warm) { if (drawAt > timeline.stamp) cold += 1; restartsWarm = timeline.restarts; continue; } delays.push(now - drawAt); frames += 1; if (drawAt > timeline.stamp) ahead += 1; for (const p of packets[next - 1]!.bodies) { const at = bodyAt(timeline, p, drawAt); paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); } } const walked = [...paths.values()] .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) .map((steps) => steps.filter((s) => s > 0.001)) .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; const { mean, spread } = spreadOf(walked); return { packets: packets.filter((p) => p.at >= warm).length, steps: walked.length, mean, spread, restarts: timeline.restarts - restartsWarm, delay: { min: Math.min(...delays), max: Math.max(...delays), now: delay(timeline) }, ahead, cold, frames, }; } /** The rule as it stood before: every other tick from the third seat. */ const halvedAtThree = (filled: number): number => (filled <= 2 ? 1 : 2); test("in a room of four or eight, everybody else is drawn 45ms behind, not 87, with no new jitter", () => { for (const seats of [4, 8]) { const before = watchRoom(seats, halvedAtThree); const after = watchRoom(seats, broadcastEvery); for (const [label, r] of [ ["before", before], [" after", after], ] as const) { console.log( `room of ${seats}, ${label}: ${r.packets} snapshots in 3s, ${r.steps} steps, mean ${r.mean.toFixed(2)}/frame, ` + `spread ${(r.spread * 100).toFixed(1)}%, restarts ${r.restarts}, ` + `delay ${(r.delay.min * 1000).toFixed(1)}–${(r.delay.max * 1000).toFixed(1)}ms (${(r.delay.now * 1000).toFixed(1)} in force), ` + `drawn past the newest sample ${r.ahead}/${r.frames} frames, ${r.cold} while the delay settled`, ); } // Thirty a second reach this seat, not fifteen. expect(after.packets).toBeGreaterThanOrEqual(85); expect(before.packets).toBeLessThanOrEqual(46); // And so every other body is drawn at the floor of the render delay, not // 1.3 intervals of a halved rate: 45ms behind the present, not 87. expect(after.delay.max).toBeLessThan(0.05); expect(before.delay.max).toBeGreaterThan(0.08); // Closer to the present buys no jitter: the steps are never less even // than they were at the halved rate, and no body in sight all along // started again. In the room of four the body measured runs clean and // holds the small room's 8% (`watchBots`); in the room of eight the two // bots left thread past the idle bodies at spawn, and those stops are the // server's, the same in both columns, so only the comparison is held. expect(after.steps).toBeGreaterThan(40); expect(after.mean * 60).toBeGreaterThan(150); if (seats === 4) expect(after.spread).toBeLessThan(0.08); expect(after.spread).toBeLessThanOrEqual(before.spread + 0.01); expect(after.restarts).toBeLessThanOrEqual(before.restarts); // A 45ms delay over a 33ms beat leaves 12ms for a packet to be late, and // the wire holds one in thirty by about that: the odd frame is drawn on // the newest pair's velocity, one interval at most, which a room of two // gets today. What must not appear is the run of guessed frames a // timeline spends settling on a halved rate. expect(after.ahead).toBeLessThanOrEqual(Math.ceil(after.frames * 0.03)); expect(before.cold).toBeGreaterThan(10); expect(after.cold).toBeLessThanOrEqual(2); } });