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

forked from benjivers/val

workbenchthis machine

Merge pull request #9 from wren/8-bots-that-never-miss-and-walls-that-make

Bots that never miss, and walls that make the view skip
benjivers committed 74434ad merge Browse files
14 files changed +1060 −82
README.md +14 −1
@@ -37,7 +37,13 @@ server:
3737 `src/arena.ts` by comparing the players either side of a tick — it carries
3838 names, the gun and an age, and never a position.
3939 - **Bots** fill every empty seat and use their kit: smokes on the way in,
40 flashes before the entry, a recon dart, a molly on the planted spike.
40 flashes before the entry, a recon dart, a molly on the planted spike. They
41 shoot through the same spread everyone does, and they aim like something
42 with hands: 200–350ms to react to a target they have just seen, an aim
43 cone that opens several degrees on the swing and only settles while they
44 hold the same target, and more of both when you are crossing their view
45 (`src/aim.ts`). Every roll of it comes from the match's seeded random, so
46 a seed replays the same round.
4147
4248 Agents: Ember, Veil, Sable, Mender, Rook — two basics bought with credits
4349 (charges last the round), a free signature on a cooldown, and an ultimate
@@ -47,6 +53,13 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
4753
4854 ## What is not
4955
56 - Your own body is *drawn* from a guess — the browser runs your own movement
57 forward from your own keys between snapshots, through the same
58 step-and-slide the server uses (`src/glide.ts`), and carries the last
59 snapshot forward by up to a quarter second of held input. It is a drawing
60 optimisation only: nothing is sent, every snapshot leans the drawn body
61 back onto the server's answer, and a real disagreement is taken outright.
62 Everybody else is still smoothed toward the last position the server sent.
5063 - The view is a raycaster: walls are one tile tall and flat-shaded, there is
5164 no looking up or down, and everything that is not a wall — bodies, the
5265 spike, ability zones — is a flat billboard scaled by distance and clipped
src/aim.test.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * How a bot points a gun, tested as the pure maths it is, and then once
3 * through a whole bot to prove the gate is actually wired to the trigger.
4 */
5
6 import { expect, test } from "bun:test";
7 import {
8 acquire,
9 aimCone,
10 canFire,
11 CONE_FLOOR,
12 crossRate,
13 drift,
14 emptyTrack,
15 FORGET,
16 heldFor,
17 lose,
18 REACTION_MAX,
19 REACTION_MIN,
20 reactionTime,
21 } from "./aim.ts";
22 import { botThink } from "./bots.ts";
23 import { newMatch, RUN_SPEED } from "./match.ts";
24 import type { MatchState, Player } from "./state.ts";
25
26 const rolls = (...values: number[]) => {
27 let at = 0;
28 return () => values[at++ % values.length]!;
29 };
30
31 test("the reaction a bot draws is between 200 and 350ms, whatever the roll", () => {
32 expect(reactionTime(0)).toBeCloseTo(REACTION_MIN, 9);
33 expect(reactionTime(1)).toBeCloseTo(REACTION_MAX, 9);
34 expect(reactionTime(0.5)).toBeCloseTo((REACTION_MIN + REACTION_MAX) / 2, 9);
35 // A roll out of range is still a reaction, not a bot that never shoots.
36 expect(reactionTime(-3)).toBe(REACTION_MIN);
37 expect(reactionTime(9)).toBe(REACTION_MAX);
38 });
39
40 test("the trigger gate is a pure function of when acquisition happened", () => {
41 const track = emptyTrack();
42 // Nothing in sight is never a shot, however long the round has run.
43 expect(canFire(track, 100)).toBe(false);
44
45 acquire(track, "bot-red-1", 10, { x: 0, y: 0 }, rolls(0, 0.5));
46 expect(track.reaction).toBeCloseTo(REACTION_MIN, 9);
47 expect(canFire(track, 10)).toBe(false);
48 expect(canFire(track, 10.19)).toBe(false);
49 expect(canFire(track, 10.21)).toBe(true);
50 expect(canFire(track, 12)).toBe(true);
51 expect(heldFor(track, 12)).toBeCloseTo(2, 9);
52 });
53
54 test("a new target starts the reaction again; the same one does not", () => {
55 const track = emptyTrack();
56 acquire(track, "one", 5, { x: 100, y: 100 }, rolls(1, 0.5));
57 expect(track.since).toBe(5);
58 acquire(track, "one", 5.3, { x: 180, y: 100 }, rolls(0, 0.5));
59 expect(track.since).toBe(5); // the hold carries on
60 expect(canFire(track, 5.4)).toBe(true);
61 acquire(track, "two", 5.4, { x: 0, y: 0 }, rolls(0, 0.5));
62 expect(track.since).toBe(5.4); // swinging onto somebody else costs again
63 expect(canFire(track, 5.5)).toBe(false);
64 });
65
66 test("a hold survives a blink out of sight, and no longer", () => {
67 const track = emptyTrack();
68 acquire(track, "one", 1, { x: 0, y: 0 }, rolls(0, 0.5));
69 lose(track, 1 + FORGET / 2);
70 expect(track.id).toBe("one");
71 // Back a moment later: the same hold, already through its reaction.
72 acquire(track, "one", 1 + FORGET / 2, { x: 0, y: 0 }, rolls(0, 0.5));
73 expect(track.since).toBe(1);
74 // Gone for longer than that and it is a fresh acquisition.
75 lose(track, 3);
76 expect(track.id).toBe(null);
77 acquire(track, "one", 3, { x: 0, y: 0 }, rolls(0, 0.5));
78 expect(track.since).toBe(3);
79 expect(canFire(track, 3.1)).toBe(false);
80 });
81
82 test("the cone starts wide, settles the longer one target is held, and never closes", () => {
83 const swing = aimCone(0, 0);
84 const second = aimCone(1, 0);
85 const settled = aimCone(3, 0);
86 expect(swing).toBeGreaterThan(0.08); // several degrees on the swing
87 expect(second).toBeLessThan(swing / 2);
88 expect(settled).toBeLessThan(second);
89 expect(settled).toBeGreaterThanOrEqual(CONE_FLOOR);
90 expect(aimCone(99, 0)).toBeCloseTo(CONE_FLOOR, 6);
91 });
92
93 test("a target crossing the view costs the bot accuracy", () => {
94 expect(aimCone(3, 1)).toBeGreaterThan(aimCone(3, 0) * 3);
95 expect(aimCone(3, 0.5)).toBeGreaterThan(aimCone(3, 0));
96 expect(aimCone(3, 0.5)).toBeLessThan(aimCone(3, 1));
97 // Nonsense from the caller is clamped, not multiplied out.
98 expect(aimCone(3, 50)).toBeCloseTo(aimCone(3, 1), 9);
99 });
100
101 test("crossing is measured across the sight line, not along it", () => {
102 const bot = { x: 0, y: 0 };
103 const walking = emptyTrack();
104 acquire(walking, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5));
105 // Straight down the barrel: nothing to track.
106 expect(crossRate(walking, bot, { x: 260, y: 0 }, 0.1)).toBeCloseTo(0, 6);
107 const strafing = emptyTrack();
108 acquire(strafing, "t", 0, { x: 300, y: 0 }, rolls(0, 0.5));
109 // Straight across it at a run: all of it but the turn of the sight line.
110 expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(250, -1);
111 // And it remembers where the target went, so the next tick measures again.
112 expect(crossRate(strafing, bot, { x: 300, y: 25 }, 0.1)).toBeCloseTo(0, 6);
113 });
114
115 test("the error wanders inside the cone instead of being redrawn every tick", () => {
116 const track = emptyTrack();
117 acquire(track, "one", 0, { x: 0, y: 0 }, rolls(0, 1));
118 // Drawn at the edge of the swing cone on acquisition.
119 expect(Math.abs(track.error)).toBeCloseTo(aimCone(0, 0), 9);
120 // One tick moves it a little, not to somewhere unrelated.
121 const before = track.error;
122 const after = drift(track, aimCone(0, 0), 1 / 30, rolls(0.9));
123 expect(Math.abs(after - before)).toBeLessThan(aimCone(0, 0) / 4);
124 // And it is squeezed toward the barrel as the cone closes around it.
125 for (let i = 0; i < 90; i++) drift(track, aimCone(3, 0), 1 / 30, rolls(0.5, 1, 0));
126 expect(Math.abs(track.error)).toBeLessThanOrEqual(aimCone(3, 0) + 1e-12);
127 });
128
129 // ------------------------------------------------------------- through a bot
130
131 const facing = (state: MatchState): { bot: Player; prey: Player } => {
132 const bot = state.players.find((p) => p.side === "def")!;
133 const prey = state.players.find((p) => p.side === "atk")!;
134 // Nose to nose in the open, with nothing between them.
135 bot.x = 1200;
136 bot.y = 640;
137 prey.x = 1200 + 200;
138 prey.y = 640;
139 bot.angle = 0;
140 bot.input.angle = 0;
141 bot.weapon = "vandal";
142 bot.ammo = 25;
143 return { bot, prey };
144 };
145
146 const live = (): MatchState => {
147 const state = newMatch(4242);
148 state.phase = "live";
149 state.clock = 100;
150 return state;
151 };
152
153 test("a bot that has just seen somebody does not shoot for a reaction", () => {
154 const state = live();
155 const { bot } = facing(state);
156 let firstFire = -1;
157 for (let i = 0; i < 30 && firstFire < 0; i++) {
158 botThink(state, bot, 1 / 30);
159 state.now += 1 / 30;
160 if (bot.input.fire) firstFire = i / 30;
161 }
162 expect(firstFire).toBeGreaterThanOrEqual(REACTION_MIN);
163 expect(firstFire).toBeLessThanOrEqual(REACTION_MAX + 0.15);
164 });
165
166 test("a bot that has been tracking you is far tighter than one that just swung on", () => {
167 const state = live();
168 const { bot, prey } = facing(state);
169 const truth = () => Math.atan2(prey.y - bot.y, prey.x - bot.x);
170 const spread: number[] = [];
171 for (let i = 0; i < 120; i++) {
172 botThink(state, bot, 1 / 30);
173 state.now += 1 / 30;
174 bot.angle = bot.input.angle; // the match loop's job, done by hand
175 spread.push(Math.abs(bot.angle - truth()));
176 }
177 const swing = Math.max(...spread.slice(0, 15));
178 const held = Math.max(...spread.slice(80));
179 expect(swing).toBeGreaterThan(held * 2);
180 expect(held).toBeLessThan(0.03);
181 });
182
183 test("a strafing target keeps a settled bot honest", () => {
184 const still = live();
185 const a = facing(still);
186 const strafing = live();
187 const b = facing(strafing);
188 let stillWorst = 0;
189 let strafeWorst = 0;
190 for (let i = 0; i < 180; i++) {
191 botThink(still, a.bot, 1 / 30);
192 botThink(strafing, b.bot, 1 / 30);
193 still.now += 1 / 30;
194 strafing.now += 1 / 30;
195 a.bot.angle = a.bot.input.angle;
196 b.bot.angle = b.bot.input.angle;
197 // One stands in the open; the other runs across the sight line.
198 b.prey.y = 640 + Math.sin(i / 8) * 90;
199 if (i > 60) {
200 stillWorst = Math.max(stillWorst, Math.abs(a.bot.angle - Math.atan2(a.prey.y - a.bot.y, a.prey.x - a.bot.x)));
201 strafeWorst = Math.max(strafeWorst, Math.abs(b.bot.angle - Math.atan2(b.prey.y - b.bot.y, b.prey.x - b.bot.x)));
202 }
203 }
204 expect(strafeWorst).toBeGreaterThan(stillWorst);
205 });
206
207 test("the aim is still deterministic in the state: one seed, one round", () => {
208 const play = (): number[] => {
209 const state = live();
210 const { bot, prey } = facing(state);
211 const out: number[] = [];
212 for (let i = 0; i < 60; i++) {
213 botThink(state, bot, 1 / 30);
214 state.now += 1 / 30;
215 bot.angle = bot.input.angle;
216 prey.x = 1400 + i;
217 out.push(bot.angle, bot.input.fire ? 1 : 0);
218 }
219 return out;
220 };
221 expect(play()).toEqual(play());
222 });
223
224 test("a full run of speed is what the crossing term is measured against", () => {
225 expect(RUN_SPEED).toBeGreaterThan(0);
226 const track = emptyTrack();
227 acquire(track, "t", 0, { x: 400, y: 0 }, rolls(0, 0.5));
228 const rate = crossRate(track, { x: 0, y: 0 }, { x: 400, y: RUN_SPEED / 30 }, 1 / 30);
229 expect(rate / RUN_SPEED).toBeCloseTo(1, 3);
230 });
src/aim.ts added +128
@@ -0,0 +1,128 @@
1 /**
2 * How a bot points a gun.
3 *
4 * 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.
8 *
9 * A hold on a target has three honest costs. It takes a moment to notice
10 * (`reaction`), the barrel starts somewhere wrong and only settles the longer
11 * the same target is held (`aimCone`), and a target crossing the view is
12 * harder to stay on than one walking down the barrel (`crossRate`).
13 *
14 * Everything here is a pure function of a track and a roll, so the caller
15 * supplies the randomness — the match's seeded `random`, so a replay of a
16 * seed still plays the same round.
17 */
18
19 export type Point = { x: number; y: number };
20
21 /** One bot's hold on one target. Scratch space, carried on the player. */
22 export type Track = {
23 /** The target being held, or null when nothing is in sight. */
24 id: string | null;
25 /** Match time the hold started: every cost here is measured from it. */
26 since: number;
27 /** Match time the target was last actually seen. */
28 seen: number;
29 /** Seconds between seeing this target and being allowed to pull. */
30 reaction: number;
31 /** How wrong the barrel is, in radians, signed. */
32 error: number;
33 /** Where the target was last seen, to measure how it is moving. */
34 x: number;
35 y: number;
36 };
37
38 export const emptyTrack = (): Track => ({ id: null, since: 0, seen: 0, reaction: 0, error: 0, x: 0, y: 0 });
39
40 /** A bot notices somebody somewhere in here, never sooner. */
41 export const REACTION_MIN = 0.2;
42 export const REACTION_MAX = 0.35;
43 /** A hold survives a blink — a smoke edge, a corner — without re-reacting. */
44 export const FORGET = 0.4;
45 /** Seconds for the cone to fall by 1/e while the same target is held. */
46 export const SETTLE = 0.8;
47 /** The cone a bot settles to: under a Vandal's own spread, never zero. */
48 export const CONE_FLOOR = 0.008;
49 /** What the cone opens to the instant a target is acquired (~6°). */
50 export const CONE_SWING = 0.105;
51 /** What a target crossing the view at a full run adds to it (~3.4°). */
52 export const CONE_MOVE = 0.06;
53 /** How fast the error wanders inside the cone, in cone-widths a second. */
54 export const DRIFT = 2.4;
55
56 const clamp01 = (n: number): number => (n < 0 ? 0 : n > 1 ? 1 : n);
57
58 /** The reaction a bot draws on acquiring a target: 200–350ms. */
59 export const reactionTime = (roll: number): number =>
60 REACTION_MIN + (REACTION_MAX - REACTION_MIN) * clamp01(roll);
61
62 /** Seconds this target has been held. */
63 export const heldFor = (track: Track, now: number): number => Math.max(0, now - track.since);
64
65 /**
66 * The trigger gate, as a pure function of when acquisition happened: no
67 * target, or a target seen less than a reaction ago, is no shot.
68 */
69 export const canFire = (track: Track, now: number): boolean =>
70 track.id !== null && now - track.since >= track.reaction;
71
72 /**
73 * The half-angle the barrel may be wrong by: wide on the swing, settling
74 * toward the floor the longer one target is held, and wider again for a
75 * target crossing the view (`crossing` is 0 for still, 1 for a full run).
76 */
77 export function aimCone(held: number, crossing: number): number {
78 return CONE_FLOOR + CONE_SWING * Math.exp(-Math.max(0, held) / SETTLE) + CONE_MOVE * clamp01(crossing);
79 }
80
81 /**
82 * Point the track at a target. A different target — or the same one after
83 * the hold was forgotten — starts a fresh hold: a new reaction to serve and
84 * an error drawn anywhere in the swing cone.
85 */
86 export function acquire(track: Track, id: string, now: number, at: Point, roll: () => number): void {
87 if (track.id !== id || now - track.seen > FORGET || now < track.since) {
88 track.id = id;
89 track.since = now;
90 track.reaction = reactionTime(roll());
91 track.error = (roll() - 0.5) * 2 * aimCone(0, 0);
92 track.x = at.x;
93 track.y = at.y;
94 }
95 track.seen = now;
96 }
97
98 /** Nothing in sight. The hold survives a blink, then it is gone. */
99 export function lose(track: Track, now: number): void {
100 if (track.id !== null && now - track.seen > FORGET) track.id = null;
101 }
102
103 /**
104 * How fast the target is crossing the bot's line of sight, in units a
105 * second. Walking down the barrel is easy to stay on; strafing is not, and
106 * it is the part `atan2` to an exact position used to give away for free.
107 */
108 export function crossRate(track: Track, bot: Point, at: Point, dt: number): number {
109 const moved = { x: at.x - track.x, y: at.y - track.y };
110 track.x = at.x;
111 track.y = at.y;
112 if (dt <= 0) return 0;
113 const ax = at.x - bot.x;
114 const ay = at.y - bot.y;
115 const len = Math.hypot(ax, ay) || 1;
116 return Math.abs(moved.x * (-ay / len) + moved.y * (ax / len)) / dt;
117 }
118
119 /**
120 * Wander the error inside the cone and hand it back. It is a held mistake,
121 * not fresh noise every tick: noise resampled at 30Hz averages to a perfect
122 * barrel over a burst, which is the thing that felt like an aimbot.
123 */
124 export function drift(track: Track, cone: number, dt: number, roll: () => number): number {
125 track.error += (roll() - 0.5) * 2 * cone * DRIFT * dt;
126 track.error = Math.max(-cone, Math.min(cone, track.error));
127 return track.error;
128 }
src/arena.ts +3
@@ -264,6 +264,9 @@ export function snapshot(viewerId: string): string {
264264 haste: round2(me.haste),
265265 spike: me.spike,
266266 planting: round2(me.planting),
267 // Your own two ways of being held still, so the browser's prediction
268 // of your own body runs at the speed the server is about to use.
269 defusing: me.defusing > 0,
267270 bot: me.bot,
268271 },
269272 players: seen.map((p) => ({
src/bots.ts +11 −3
@@ -8,8 +8,9 @@
88 */
99
1010 import { agentById, castable } from "./abilities.ts";
11 import { acquire, aimCone, canFire, crossRate, drift, heldFor, lose } from "./aim.ts";
1112 import { pathStep, siteAt, siteCentre, walkDistance, type Point, type Site } from "./map.ts";
12 import { buyAbility, buyShield, buyWeapon, random, SPIKE_REACH, visible } from "./match.ts";
13 import { buyAbility, buyShield, buyWeapon, random, RUN_SPEED, SPIKE_REACH, visible } from "./match.ts";
1314 import type { MatchState, Player, Slot } from "./state.ts";
1415 import { WEAPONS, type WeaponId } from "./weapons.ts";
1516
@@ -158,17 +159,24 @@ export function botThink(state: MatchState, bot: Player, dt: number): void {
158159 // --- the gun
159160 bot.input.reload = bot.ammo === 0;
160161 if (!target) {
162 lose(bot.aim, state.now);
161163 bot.input.fire = false;
162164 if (Math.hypot(bot.input.mx, bot.input.my) > 0.01)
163165 turn(bot, Math.atan2(bot.input.my, bot.input.mx), 6 * dt);
164166 return;
165167 }
168 // A hold costs what a hold costs: a moment to notice, a barrel that starts
169 // wrong and settles, and more of both when the target is crossing the view.
170 const roll = () => random(state);
171 acquire(bot.aim, target.id, state.now, target, roll);
172 const crossing = crossRate(bot.aim, bot, target, dt) / RUN_SPEED;
173 const cone = aimCone(heldFor(bot.aim, state.now), crossing);
166174 const range = Math.hypot(target.x - bot.x, target.y - bot.y);
167 const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + (random(state) - 0.5) * 0.06;
175 const aim = Math.atan2(target.y - bot.y, target.x - bot.x) + drift(bot.aim, cone, dt, roll);
168176 turn(bot, aim, 7 * dt);
169177 const off = Math.abs(((aim - bot.angle + Math.PI * 3) % (Math.PI * 2)) - Math.PI);
170178 const weapon = WEAPONS[bot.weapon];
171 bot.input.fire = off < 0.07 && range < weapon.range && bot.ammo > 0;
179 bot.input.fire = canFire(bot.aim, state.now) && off < 0.07 && range < weapon.range && bot.ammo > 0;
172180 // Stand still to shoot at range, close the gap up front.
173181 if (range > 420) {
174182 bot.input.mx *= 0.15;
src/client.test.ts +59 −3
@@ -21,6 +21,10 @@ const base = `http://${server.hostname}:${server.port}`;
2121 // ------------------------------------------------------------ the stub DOM
2222
2323 const drawn: string[] = [];
24 // Where the canvas was moved to: on the top-down view your own body is an
25 // arrow drawn about the origin, so the last translate of a frame is where
26 // this client thinks you are.
27 const moves: { x: number; y: number }[] = [];
2428 const listeners = new Map<string, ((event: unknown) => void)[]>();
2529 const frames: ((now: number) => void)[] = [];
2630
@@ -36,7 +40,10 @@ const context = () =>
3640 if (prop in target) return target[prop];
3741 if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
3842 if (prop === "globalAlpha" || prop === "lineWidth") return 1;
39 return (...args: unknown[]) => drawn.push(`${prop}(${args.length})`);
43 return (...args: unknown[]) => {
44 if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) });
45 return drawn.push(`${prop}(${args.length})`);
46 };
4047 },
4148 set: () => true,
4249 },
@@ -97,6 +104,9 @@ const click = (node: Record<string, unknown>) => {
97104 const press = (code: string, key = "") => {
98105 for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
99106 };
107 const release = (code: string) => {
108 for (const fn of listeners.get("keyup") ?? []) fn({ code });
109 };
100110
101111 const nodes = new Map<string, Record<string, unknown>>();
102112 for (const id of [
@@ -160,9 +170,9 @@ globalThis.WebSocket = function (url: string) {
160170 return openedSocket;
161171 } as unknown as typeof WebSocket;
162172
163 const runFrame = () => {
173 const runFrame = (at = performance.now()) => {
164174 const pending = frames.splice(0, frames.length);
165 for (const fn of pending) fn(performance.now());
175 for (const fn of pending) fn(at);
166176 };
167177 const until = async (what: () => boolean, ms = 4000) => {
168178 const stop = Date.now() + ms;
@@ -274,6 +284,52 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
274284 // And the socket really took a seat in the running match.
275285 expect(seat.bot).toBe(false);
276286
287 // Your own body is drawn from a guess about your own movement, not from
288 // the last thing the server said. With the arena stopped — no snapshot can
289 // arrive at all — the drawn body still carries the keys you are holding
290 // forward, as far as the cap and no further, and stops there.
291 press("KeyV"); // the top-down view, where your own body is an arrow
292 for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
293 await Bun.sleep(60);
294 runFrame();
295 arena.stopArena();
296 // Walk whichever way is open from where the server last put us.
297 for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
298 const { blockedCircle } = await import("./map.ts");
299 const clear = ([
300 ["KeyW", 0, -1],
301 ["KeyS", 0, 1],
302 ["KeyA", -1, 0],
303 ["KeyD", 1, 0],
304 ] as const).find(([, dx, dy]) =>
305 [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
306 )!;
307 expect(clear).toBeDefined();
308 press(clear[0]);
309 const arrow = () => moves[moves.length - 1]!;
310 const startedAt = { x: seat.x, y: seat.y };
311 const scale = 900 / (30 * 80); // canvas pixels to a world unit
312 const t0 = performance.now();
313 for (let i = 1; i <= 10; i++) {
314 moves.length = 0;
315 runFrame(t0 + i * 40);
316 }
317 const drawnAt = arrow();
318 // The drawn body is ahead of the server's last word by the input held
319 // since it was sent — a quarter of a second of running at most, and then
320 // it waits, because past that a guess would be a claim. Smoothing toward
321 // a snapshot that cannot arrive would have gone nowhere at all.
322 const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
323 const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
324 expect(lead).toBeGreaterThan(30);
325 expect(lead).toBeLessThan(0.25 * 250 + 10);
326 expect(Math.abs(sideways)).toBeLessThan(6);
327 // Nothing on the server moved: this is a drawing, and the match is still
328 // the only thing that says where anybody is.
329 expect(seat.x).toBe(startedAt.x);
330 expect(seat.y).toBe(startedAt.y);
331 arena.startArena();
332
277333 // This test's loopback address is shared with every other test in the
278334 // run; close the socket so the address is free for the next real
279335 // connection rather than left holding a seat for the rest of the suite.
src/glide.test.ts added +110
@@ -0,0 +1,110 @@
1 /**
2 * Step and slide, on a grid written out here rather than the real map, so
3 * the cases are the ones being argued about: a wall, an inner corner, and a
4 * body already pressed against both.
5 */
6
7 import { expect, test } from "bun:test";
8 import { BODY_R, circleBlocked, moveSpeed, RUN, slideCircle } from "./glide.ts";
9 import { moveCircle } from "./map.ts";
10
11 const TILE = 80;
12 // A room with solid walls, open from (1,1) to (3,3).
13 const room = ["#####", "#...#", "#...#", "#...#", "#####"];
14 const solid = (col: number, row: number): boolean =>
15 col < 0 || row < 0 || row >= room.length || col >= room[0]!.length || room[row]![col] === "#";
16
17 const slide = (x: number, y: number, dx: number, dy: number) =>
18 slideCircle(x, y, dx, dy, BODY_R, solid, TILE);
19
20 test("an unobstructed step is the step it was asked for", () => {
21 const to = slide(200, 200, 12, -7);
22 expect(to.x).toBeCloseTo(212, 9);
23 expect(to.y).toBeCloseTo(193, 9);
24 expect(slide(200, 200, 0, 0)).toEqual({ x: 200, y: 200 });
25 });
26
27 test("a wall keeps the motion along it: running into it still slides", () => {
28 // Hard against the right-hand wall (x = 320 - 16), running down and right.
29 const to = slide(300, 160, 10, 10);
30 expect(to.y).toBeCloseTo(170, 6); // the free axis goes through whole
31 expect(to.x).toBeLessThanOrEqual(304);
32 expect(to.x).toBeGreaterThan(300); // and the blocked one creeps up to the wall
33 expect(circleBlocked(to.x, to.y, BODY_R, solid, TILE)).toBe(false);
34 });
35
36 test("a corner is one place, whichever axis is resolved first", () => {
37 // The bottom-right inner corner of the room: both walls at once.
38 const diagonal = slide(280, 280, 40, 40);
39 // Getting there one axis at a time, either order, lands in the same place.
40 const alongX = slide(280, 280, 40, 0);
41 const xThenY = slide(alongX.x, alongX.y, 0, 40);
42 const alongY = slide(280, 280, 0, 40);
43 const yThenX = slide(alongY.x, alongY.y, 40, 0);
44 expect(xThenY.x).toBeCloseTo(diagonal.x, 3);
45 expect(xThenY.y).toBeCloseTo(diagonal.y, 3);
46 expect(yThenX.x).toBeCloseTo(diagonal.x, 3);
47 expect(yThenX.y).toBeCloseTo(diagonal.y, 3);
48 // And it is the corner: a body's radius from both walls, inside them.
49 expect(diagonal.x).toBeGreaterThan(320 - BODY_R - 1.5);
50 expect(diagonal.x).toBeLessThanOrEqual(320 - BODY_R);
51 expect(diagonal.y).toBeGreaterThan(320 - BODY_R - 1.5);
52 expect(diagonal.y).toBeLessThanOrEqual(320 - BODY_R);
53 });
54
55 test("a body held against a wall stops moving instead of alternating", () => {
56 // This is the skip: the old resolution snapped to the tile edge on the
57 // blocked tick and moved freely on the next, so a player leaning on a
58 // wall produced a position that changed every tick with no input change.
59 let at = { x: 300, y: 200 };
60 const seen: number[] = [];
61 for (let i = 0; i < 12; i++) {
62 at = slide(at.x, at.y, 8.3, 0);
63 seen.push(at.x);
64 }
65 const settled = seen.slice(4);
66 expect(Math.max(...settled) - Math.min(...settled)).toBeLessThan(0.05);
67 expect(Math.max(...settled)).toBeLessThanOrEqual(320 - BODY_R);
68 });
69
70 test("a corner is a fixed point: pressed into it, nothing moves at all", () => {
71 let at = { x: 300, y: 300 };
72 for (let i = 0; i < 6; i++) at = slide(at.x, at.y, 8.3, 8.3);
73 const settled = slide(at.x, at.y, 8.3, 8.3);
74 expect(settled.x).toBeCloseTo(at.x, 6);
75 expect(settled.y).toBeCloseTo(at.y, 6);
76 });
77
78 test("a step never ends inside a wall, from anywhere in the room", () => {
79 for (let x = 96; x < 320 - 16; x += 7)
80 for (let y = 96; y < 320 - 16; y += 7)
81 for (const [dx, dy] of [
82 [30, 0],
83 [-30, 0],
84 [0, 30],
85 [0, -30],
86 [24, 24],
87 [-24, 24],
88 [24, -24],
89 [-24, -24],
90 ] as const) {
91 const to = slide(x, y, dx, dy);
92 expect(circleBlocked(to.x, to.y, BODY_R, solid, TILE)).toBe(false);
93 }
94 });
95
96 test("the real map moves by the same maths", () => {
97 // Mid, walking east into whatever is there: the map's own binding.
98 const to = moveCircle(1200, 640, 9, 0, BODY_R);
99 expect(to.x).toBeGreaterThanOrEqual(1200);
100 expect(to.x).toBeLessThanOrEqual(1209);
101 expect(to.y).toBe(640);
102 });
103
104 test("speed is the one table both sides read", () => {
105 expect(moveSpeed({})).toBe(RUN);
106 expect(moveSpeed({ haste: true })).toBeCloseTo(RUN * 1.35, 9);
107 expect(moveSpeed({ slow: true })).toBeCloseTo(RUN * 0.55, 9);
108 expect(moveSpeed({ heavy: true })).toBeCloseTo(RUN * 0.86, 9);
109 expect(moveSpeed({ stopped: true, haste: true })).toBe(0);
110 });
src/glide.ts added +126
@@ -0,0 +1,126 @@
1 /**
2 * How a body moves through a grid of solid tiles: the collision, the slide
3 * along a wall, and how fast it goes.
4 *
5 * One module because two programs have to agree about it. The server owns
6 * where everybody is; the browser predicts where *you* are between the
7 * snapshots it is sent, and a prediction that resolves a wall differently
8 * from the server is a camera that fights the server every tick. Written
9 * twice this drifts; written once, the guess lands on the answer.
10 *
11 * Nothing here knows about the map, the match or a player: the caller says
12 * which tiles are solid and how big one is. It is the server's `map.ts` and
13 * the browser's `map.json` asking the same question of the same maths.
14 */
15
16 export type Solid = (col: number, row: number) => boolean;
17 export type Point = { x: number; y: number };
18
19 /** The radius of a body on this map, and how fast one runs. */
20 export const BODY_R = 16;
21 export const RUN = 250;
22
23 /** How near a wall a refused step creeps: halvings of what was refused. */
24 const CREEP_STEPS = 6;
25
26 /** True when a circle at (x,y) overlaps any solid tile. */
27 export function circleBlocked(x: number, y: number, r: number, solid: Solid, tile: number): boolean {
28 const c0 = Math.floor((x - r) / tile);
29 const c1 = Math.floor((x + r) / tile);
30 const r0 = Math.floor((y - r) / tile);
31 const r1 = Math.floor((y + r) / tile);
32 for (let row = r0; row <= r1; row++)
33 for (let col = c0; col <= c1; col++) {
34 if (!solid(col, row)) continue;
35 const nearX = Math.max(col * tile, Math.min(x, col * tile + tile));
36 const nearY = Math.max(row * tile, Math.min(y, row * tile + tile));
37 const dx = x - nearX;
38 const dy = y - nearY;
39 if (dx * dx + dy * dy < r * r) return true;
40 }
41 return false;
42 }
43
44 /** The largest fraction of a refused step that is still free, by halving. */
45 function creep(delta: number, free: (v: number) => boolean): number {
46 let lo = 0;
47 let hi = delta;
48 for (let i = 0; i < CREEP_STEPS; i++) {
49 const mid = (lo + hi) / 2;
50 if (free(mid)) lo = mid;
51 else hi = mid;
52 }
53 return lo;
54 }
55
56 /**
57 * Move a circle by (dx,dy), sliding along whatever it runs into.
58 *
59 * The motion is projected onto the wall it meets — the blocked component is
60 * dropped, never snapped to the tile edge. A snap makes a body against a
61 * wall alternate between a snapped coordinate and a free one, which is a
62 * position that disagrees with the input every other tick; a projection is
63 * a fixed point, so a body pressed into a corner stays exactly where it is.
64 *
65 * Which component survives is decided by the motion (the longer one wins a
66 * corner it is clipping), not by an axis resolved first, so walking into a
67 * corner ends in one place whichever way the diagonal is written.
68 */
69 export function slideCircle(
70 x: number,
71 y: number,
72 dx: number,
73 dy: number,
74 r: number,
75 solid: Solid,
76 tile: number,
77 ): Point {
78 if (dx === 0 && dy === 0) return { x, y };
79 const free = (px: number, py: number): boolean => !circleBlocked(px, py, r, solid, tile);
80 if (free(x + dx, y + dy)) return { x: x + dx, y: y + dy };
81
82 const okX = dx !== 0 && free(x + dx, y);
83 const okY = dy !== 0 && free(x, y + dy);
84 const longerIsX = Math.abs(dx) >= Math.abs(dy);
85 let ax = okX ? dx : 0;
86 let ay = okY ? dy : 0;
87 // Both axes open but the diagonal shut is a corner being clipped: the
88 // longer component goes through whole and the shorter one creeps.
89 if (okX && okY) {
90 if (longerIsX) ay = 0;
91 else ax = 0;
92 }
93
94 let nx = x + ax;
95 let ny = y + ay;
96 // Whatever was refused still creeps as near the wall as it fits, rather
97 // than stopping a whole step short of it — a step is a third of a body.
98 const creepX = (): void => {
99 if (ax === 0 && dx !== 0) nx = x + creep(dx, (v) => free(x + v, ny));
100 };
101 const creepY = (): void => {
102 if (ay === 0 && dy !== 0) ny = y + creep(dy, (v) => free(nx, y + v));
103 };
104 if (longerIsX) {
105 creepX();
106 creepY();
107 } else {
108 creepY();
109 creepX();
110 }
111 return { x: nx, y: ny };
112 }
113
114 /**
115 * How fast a body moves, given what is on it. The browser reads the same
116 * four facts out of its snapshot that the match reads off a player, so a
117 * prediction runs at the speed the server is about to use.
118 */
119 export function moveSpeed(state: { haste?: boolean; slow?: boolean; stopped?: boolean; heavy?: boolean }): number {
120 if (state.stopped) return 0;
121 let speed = RUN;
122 if (state.haste) speed *= 1.35;
123 if (state.slow) speed *= 0.55;
124 if (state.heavy) speed *= 0.86;
125 return speed;
126 }
src/map.ts +8 −41
@@ -11,6 +11,8 @@
1111 * and the walk distance a bot uses to path. No state.
1212 */
1313
14 import { circleBlocked, slideCircle } from "./glide.ts";
15
1416 export const TILE = 80;
1517
1618 // '#' wall · '.' floor · 'A'/'B' plant site · 'a' attacker spawn · 'd' defender spawn
@@ -94,50 +96,15 @@ export const siteCentre = (site: Site): Point => {
9496 export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)];
9597
9698 /**
97 * Slide a circle through the grid: each axis is moved and resolved on its
98 * own, so running along a wall slides instead of sticking.
99 * Slide a circle through this grid. The maths is in `glide.ts`, which the
100 * browser runs too; here it is bound to this map's tiles.
99101 */
100 export function moveCircle(x: number, y: number, dx: number, dy: number, r: number): Point {
101 let nx = x;
102 let ny = y;
103 if (dx !== 0) {
104 const test = nx + dx;
105 if (!blockedCircle(test, ny, r)) nx = test;
106 else {
107 const col = Math.floor(test / TILE);
108 nx = dx > 0 ? col * TILE - r - 0.01 : (col + 1) * TILE + r + 0.01;
109 if (blockedCircle(nx, ny, r)) nx = x;
110 }
111 }
112 if (dy !== 0) {
113 const test = ny + dy;
114 if (!blockedCircle(nx, test, r)) ny = test;
115 else {
116 const row = Math.floor(test / TILE);
117 ny = dy > 0 ? row * TILE - r - 0.01 : (row + 1) * TILE + r + 0.01;
118 if (blockedCircle(nx, ny, r)) ny = y;
119 }
120 }
121 return { x: nx, y: ny };
122 }
102 export const moveCircle = (x: number, y: number, dx: number, dy: number, r: number): Point =>
103 slideCircle(x, y, dx, dy, r, solidTile, TILE);
123104
124105 /** True when a circle at (x,y) overlaps any solid tile. */
125 export function blockedCircle(x: number, y: number, r: number): boolean {
126 const c0 = Math.floor((x - r) / TILE);
127 const c1 = Math.floor((x + r) / TILE);
128 const r0 = Math.floor((y - r) / TILE);
129 const r1 = Math.floor((y + r) / TILE);
130 for (let row = r0; row <= r1; row++)
131 for (let col = c0; col <= c1; col++) {
132 if (!solidTile(col, row)) continue;
133 const nearX = Math.max(col * TILE, Math.min(x, col * TILE + TILE));
134 const nearY = Math.max(row * TILE, Math.min(y, row * TILE + TILE));
135 const dx = x - nearX;
136 const dy = y - nearY;
137 if (dx * dx + dy * dy < r * r) return true;
138 }
139 return false;
140 }
106 export const blockedCircle = (x: number, y: number, r: number): boolean =>
107 circleBlocked(x, y, r, solidTile, TILE);
141108
142109 /**
143110 * Does a wall stand between two points? Sampled every third of a tile: a
src/match.ts +14 −8
@@ -9,6 +9,8 @@
99 */
1010
1111 import { cast, castable, agentById, buyableSlots, flashDuration, sightBlocked, SLOTS } from "./abilities.ts";
12 import { emptyTrack } from "./aim.ts";
13 import { BODY_R, moveSpeed, RUN } from "./glide.ts";
1214 import { award, clampCredits, roundReward, spend, START_CREDITS } from "./economy.ts";
1315 import {
1416 blockedCircle,
@@ -24,8 +26,10 @@ import {
2426 import { emptyInput, type Input, type MatchState, type Player, type Side, type Slot, type Team } from "./state.ts";
2527 import { applyDamage, damage, shotSpread, SHIELD, WEAPONS, zoneFromMiss, type WeaponId } from "./weapons.ts";
2628
27 export const PLAYER_R = 16;
28 export const RUN_SPEED = 250;
29 // A body's size and its speed are shared with the browser, which predicts
30 // your own movement between snapshots: `src/glide.ts` is the one copy.
31 export const PLAYER_R = BODY_R;
32 export const RUN_SPEED = RUN;
2933 export const BUY_TIME = 14;
3034 export const ROUND_TIME = 100;
3135 export const PLANT_TIME = 4;
@@ -137,6 +141,7 @@ export function makePlayer(
137141 input: emptyInput(),
138142 goal: null,
139143 think: 0,
144 aim: emptyTrack(),
140145 spray: 0,
141146 joinedAt: 0,
142147 };
@@ -229,6 +234,7 @@ export function startRound(state: MatchState, first = false): void {
229234 player.spray = 0;
230235 player.goal = null;
231236 player.think = 0;
237 player.aim = emptyTrack();
232238 player.input = emptyInput();
233239 // Basics are bought fresh every round; the signature comes back free.
234240 player.charges = { c: 0, q: 0, e: 1, x: 0 };
@@ -345,12 +351,12 @@ export function tick(state: MatchState, dt: number): void {
345351 }
346352
347353 function speedOf(player: Player): number {
348 let speed = RUN_SPEED;
349 if (player.haste > 0) speed *= 1.35;
350 if (player.slow > 0) speed *= 0.55;
351 if (player.planting > 0 || player.defusing > 0) speed = 0;
352 if (player.weapon === "operator") speed *= 0.86;
353 return speed;
354 return moveSpeed({
355 haste: player.haste > 0,
356 slow: player.slow > 0,
357 stopped: player.planting > 0 || player.defusing > 0,
358 heavy: player.weapon === "operator",
359 });
354360 }
355361
356362 function movePlayer(state: MatchState, player: Player, dt: number, penned: boolean): void {
src/predict.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * The browser's guess about where you are, checked against the server's
3 * answer: the same maths, the same wall, and a disagreement that closes.
4 */
5
6 import { expect, test } from "bun:test";
7 import { BODY_R, moveSpeed } from "./glide.ts";
8 import { moveCircle, solidTile, TILE } from "./map.ts";
9 import { advance, anchor, holdBack, insideWall, LEAD_MAX, makeLocal, project, reconcile, SNAP } from "../static/predict.js";
10
11 // The world the browser builds out of map.json, which is the map the server
12 // plays on: the same rows, the same tile, the same body.
13 const world = { solid: solidTile, tile: TILE, r: BODY_R };
14
15 test("nothing is drawn until the server has said where you are", () => {
16 const local = makeLocal();
17 expect(local.ready).toBe(false);
18 advance(local, 1, 0, 250, 1 / 60, world);
19 expect(local).toMatchObject({ x: 0, y: 0 });
20 reconcile(local, { x: 1200, y: 640 }, 1 / 60);
21 expect(local).toMatchObject({ x: 1200, y: 640, ready: true });
22 });
23
24 test("a frame of movement is the speed it was given, in the direction it was given", () => {
25 const local = anchor(makeLocal(), { x: 1200, y: 640 });
26 advance(local, 1, 0, 250, 1 / 50, world);
27 expect(local.x).toBeCloseTo(1205, 6);
28 expect(local.y).toBeCloseTo(640, 6);
29 // A diagonal is a direction, not a speed boost.
30 const diagonal = anchor(makeLocal(), { x: 1200, y: 640 });
31 advance(diagonal, 1, 1, 250, 1 / 50, world);
32 expect(Math.hypot(diagonal.x - 1200, diagonal.y - 640)).toBeCloseTo(5, 6);
33 // Standing still, planted, or stopped to defuse: nothing moves.
34 const still = anchor(makeLocal(), { x: 1200, y: 640 });
35 advance(still, 0, 0, 250, 1 / 50, world);
36 advance(still, 1, 0, moveSpeed({ stopped: true }), 1 / 50, world);
37 expect(still).toMatchObject({ x: 1200, y: 640 });
38 });
39
40 test("the guess resolves a wall exactly the way the server does", () => {
41 // Every open tile of the real map, walked into from eight directions: the
42 // browser's step and the server's land in the same place, or the camera
43 // spends the round being dragged off whatever it guessed.
44 const rows = 19;
45 const cols = 30;
46 let tried = 0;
47 for (let row = 1; row < rows - 1; row++)
48 for (let col = 1; col < cols - 1; col++) {
49 if (solidTile(col, row)) continue;
50 const x = col * TILE + TILE / 2;
51 const y = row * TILE + TILE / 2;
52 for (const [mx, my] of [
53 [1, 0],
54 [-1, 0],
55 [0, 1],
56 [0, -1],
57 [0.7, 0.7],
58 [-0.7, 0.7],
59 [0.7, -0.7],
60 [-0.7, -0.7],
61 ]) {
62 const local = anchor(makeLocal(), { x, y });
63 advance(local, mx, my, 250, 1 / 15, world);
64 const server = moveCircle(x, y, mx * 250 * (1 / 15), my * 250 * (1 / 15), BODY_R);
65 expect(local.x).toBeCloseTo(server.x, 9);
66 expect(local.y).toBeCloseTo(server.y, 9);
67 tried += 1;
68 }
69 }
70 expect(tried).toBeGreaterThan(1000);
71 });
72
73 test("a disagreement is leaned off, not jumped", () => {
74 const local = anchor(makeLocal(), { x: 1200, y: 640 });
75 reconcile(local, { x: 1210, y: 640 }, 1 / 60);
76 expect(local.gap).toBeCloseTo(10, 6);
77 // A frame moves a fraction of the way, never the whole way.
78 expect(local.x).toBeGreaterThan(1200);
79 expect(local.x).toBeLessThan(1203);
80 // Held there, it closes.
81 for (let i = 0; i < 60; i++) reconcile(local, { x: 1210, y: 640 }, 1 / 60);
82 expect(local.x).toBeCloseTo(1210, 2);
83 });
84
85 test("a real disagreement is taken outright: the server is the answer", () => {
86 const local = anchor(makeLocal(), { x: 1200, y: 640 });
87 // A spawn, a teleport, a seat handed to somebody else mid-round.
88 reconcile(local, { x: 1200 + SNAP + 1, y: 640 }, 1 / 60);
89 expect(local.x).toBe(1200 + SNAP + 1);
90 expect(local.gap).toBe(0);
91 });
92
93 test("a snapshot is carried forward by the input held since it was sent", () => {
94 const server = { x: 1200, y: 640 };
95 // Holding nothing, the answer is the answer: no lead, nothing invented.
96 expect(project(server, 0, 0, 250, 0.1, world)).toEqual({ x: 1200, y: 640 });
97 expect(project(server, 1, 0, 0, 0.1, world)).toEqual({ x: 1200, y: 640 });
98 expect(project(server, 1, 0, 250, 0, world)).toEqual({ x: 1200, y: 640 });
99 // Holding east for 66ms, it is 66ms of running east ahead.
100 const ahead = project(server, 1, 0, 250, 1 / 15, world);
101 expect(ahead.x - 1200).toBeCloseTo(250 / 15, 6);
102 // A connection that stopped talking is not a licence to run off the map.
103 const far = project(server, 1, 0, 250, 30, world);
104 expect(far.x - 1200).toBeLessThanOrEqual(250 * LEAD_MAX);
105 // And the carry is the server's own step-and-slide: never through a wall.
106 for (let y = 100; y < 1400; y += 37) {
107 const from = { x: 300, y };
108 if (insideWall(from, world)) continue; // not a place anybody can stand
109 expect(insideWall(project(from, -1, 0, 250, LEAD_MAX, world), world)).toBe(false);
110 }
111 });
112
113 test("the guess is never drawn past the answer it is running toward", () => {
114 const target = { x: 1300, y: 640 };
115 // Ahead of the target along the way it is going: pulled back onto it.
116 const past = anchor(makeLocal(), { x: 1320, y: 640 });
117 holdBack(past, target, 1, 0);
118 expect(past.x).toBeCloseTo(1300, 9);
119 // Short of it, or across it, or standing still: left alone.
120 const short = anchor(makeLocal(), { x: 1280, y: 648 });
121 holdBack(short, target, 1, 0);
122 expect(short).toMatchObject({ x: 1280, y: 648 });
123 const still = anchor(makeLocal(), { x: 1320, y: 640 });
124 holdBack(still, target, 0, 0);
125 expect(still.x).toBe(1320);
126 });
127
128 test("a guess running with the server stays within a stride of it, and never inside a wall", () => {
129 // Sixty frames a second guessing, fifteen a second corrected, walking east
130 // into the wall that bounds mid. This is the case that used to skip.
131 const speed = 250;
132 const start = { x: 1200, y: 640 };
133 let server = { ...start };
134 const local = anchor(makeLocal(), start);
135 let worst = 0;
136 let back = 0;
137 let previous = local.x;
138 for (let frame = 0; frame < 240; frame++) {
139 advance(local, 1, 0, speed, 1 / 60, world);
140 // The server ticks at 30Hz and is heard from at 15Hz.
141 if (frame % 2 === 0) server = moveCircle(server.x, server.y, speed / 30, 0, BODY_R);
142 if (frame % 4 === 0) reconcile(local, server, 1 / 15);
143 worst = Math.max(worst, Math.hypot(local.x - server.x, local.y - server.y));
144 // The drawn body never walks backwards: that is what a skip looks like.
145 back = Math.min(back, local.x - previous);
146 previous = local.x;
147 expect(insideWall(local, world)).toBe(false);
148 }
149 expect(worst).toBeLessThan(BODY_R);
150 expect(back).toBeGreaterThan(-0.01);
151 });
src/state.ts +3
@@ -3,6 +3,7 @@
33 * can both speak about a player without importing each other.
44 */
55
6 import type { Track } from "./aim.ts";
67 import type { Point, Site } from "./map.ts";
78 import type { WeaponId } from "./weapons.ts";
89
@@ -97,6 +98,8 @@ export type Player = {
9798 /** Bot scratch space: where it is walking and when it last thought. */
9899 goal: Point | null;
99100 think: number;
101 /** Bot scratch space: the target it is holding, and how badly. */
102 aim: Track;
100103 spray: number;
101104 joinedAt: number;
102105 };
static/predict.js added +115
@@ -0,0 +1,115 @@
1 /**
2 * Where you are, drawn at the frame rate.
3 *
4 * Everybody else in a snapshot is smoothed toward the last position the
5 * server sent, which is right for a body you only hear about fifteen times a
6 * second. Doing it to your own body is what made the view skip: the camera
7 * was always being dragged toward a position taken sixty-six milliseconds
8 * ago, and against a wall the server kept correcting it besides.
9 *
10 * So your own body is run forward here instead, from your own keys, through
11 * the same step-and-slide the server uses (`src/glide.ts` — one copy, or the
12 * guess fights the answer at every wall). The server is still the only thing
13 * that decides where you are: every snapshot leans the drawn body back
14 * toward its answer, and a real disagreement is taken outright. This is a
15 * drawing optimisation and nothing here is ever sent anywhere.
16 */
17
18 import { circleBlocked, slideCircle } from "../src/glide.ts";
19
20 /** Past this much disagreement the server simply wins: a spawn, a teleport. */
21 export const SNAP = 40;
22 /** How fast the rest of a disagreement is leaned off, a second. */
23 export const PULL = 12;
24 /** How far ahead of a snapshot your own input is ever carried, in seconds. */
25 export const LEAD_MAX = 0.25;
26
27 export const makeLocal = () => ({ x: 0, y: 0, ready: false, gap: 0 });
28
29 /** Take the server's word for it outright. */
30 export function anchor(local, at) {
31 local.x = at.x;
32 local.y = at.y;
33 local.ready = true;
34 local.gap = 0;
35 return local;
36 }
37
38 /**
39 * One frame of your own movement: a direction, the speed the server will
40 * move you at, and the world to slide against — `{ solid, tile, r }`.
41 */
42 export function advance(local, mx, my, speed, dt, world) {
43 if (!local.ready) return local;
44 const len = Math.hypot(mx, my);
45 if (len > 1) {
46 mx /= len;
47 my /= len;
48 }
49 if (speed <= 0 || (mx === 0 && my === 0) || dt <= 0) return local;
50 const to = slideCircle(local.x, local.y, mx * speed * dt, my * speed * dt, world.r, world.solid, world.tile);
51 local.x = to.x;
52 local.y = to.y;
53 return local;
54 }
55
56 /**
57 * Where the server's last answer has got to by now.
58 *
59 * A snapshot is a position from a moment ago, and the input you have held
60 * since it was sent has not reached it yet. Rather than a sequence number
61 * the server echoes back, the unacknowledged part of your own movement is
62 * measured in time: the age of the snapshot, capped, walked through the
63 * same slide. Hold nothing and this is the server's answer exactly, which
64 * is the point — it is what the drawn body settles onto.
65 */
66 export function project(at, mx, my, speed, age, world) {
67 const len = Math.hypot(mx, my);
68 if (len > 1) {
69 mx /= len;
70 my /= len;
71 }
72 const ahead = Math.min(LEAD_MAX, Math.max(0, age));
73 if (speed <= 0 || ahead === 0 || (mx === 0 && my === 0)) return { x: at.x, y: at.y };
74 return slideCircle(at.x, at.y, mx * speed * ahead, my * speed * ahead, world.r, world.solid, world.tile);
75 }
76
77 /**
78 * Lean the drawn body toward the server's answer. A small disagreement —
79 * the width of the guess, a shot of lag — is walked off over a moment; a
80 * large one is not a disagreement but a different place, and is taken.
81 */
82 export function reconcile(local, server, dt) {
83 if (!local.ready) return anchor(local, server);
84 const dx = server.x - local.x;
85 const dy = server.y - local.y;
86 local.gap = Math.hypot(dx, dy);
87 if (local.gap > SNAP) return anchor(local, server);
88 const k = 1 - Math.exp(-PULL * Math.max(0, dt));
89 local.x += dx * k;
90 local.y += dy * k;
91 return local;
92 }
93
94 /**
95 * Never past the answer. The guess may fill the gap to where the server's
96 * word and your own held keys put you and no further: left to run, a guess
97 * that moves faster than its target is corrected finds its own equilibrium
98 * somewhere beyond it, which is a claim about a position rather than a
99 * drawing of one.
100 */
101 export function holdBack(local, target, mx, my) {
102 const len = Math.hypot(mx, my);
103 if (len === 0) return local;
104 const dx = mx / len;
105 const dy = my / len;
106 const over = (local.x - target.x) * dx + (local.y - target.y) * dy;
107 if (over > 0) {
108 local.x -= dx * over;
109 local.y -= dy * over;
110 }
111 return local;
112 }
113
114 /** A body drawn inside a wall is worse than a body drawn late. */
115 export const insideWall = (local, world) => circleBlocked(local.x, local.y, world.r, world.solid, world.tile);
static/val.js +88 −26
@@ -12,7 +12,9 @@
1212 "use strict";
1313
1414 import { makeBeeper } from "./beep.js";
15 import { BODY_R, moveSpeed } from "../src/glide.ts";
1516 import { drawGun, gunSnapshot, gunTick, makeGun } from "./gun.js";
17 import { advance, anchor, holdBack, insideWall, makeLocal, project, reconcile } from "./predict.js";
1618 import { choosePrompt, REACH } from "./prompt.js";
1719 import { roster, rosterKey } from "./roster.js";
1820 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
@@ -30,6 +32,7 @@ const HELP_SEEN = "val.help.seen";
3032
3133 let map = null;
3234 let solid = () => true;
35 let world = null; // what predict.js slides against: the grid, from map.json
3336 let snap = null;
3437 let socket = null;
3538 let agents = null;
@@ -45,8 +48,17 @@ let stick = null; // { id, ox, oy, mx, my }
4548 let aimTouch = null;
4649 let helpShown = false;
4750 let last = performance.now();
51 let snapAt = last; // when the last snapshot landed: how stale the answer is
4852
4953 const walk = { x: 0, y: 0 };
54 // What this person is doing right now, read once a frame for the drawing and
55 // once every 50ms for the server. The same object either way: no allocation
56 // in a loop that runs sixty times a second.
57 const intent = { mx: 0, my: 0, angle: 0, fire: false, use: false, reload: false };
58 // Your own body, run forward from your own keys between snapshots, and the
59 // object the drawing is handed for it.
60 const local = makeLocal();
61 const drawnMe = { id: "", x: 0, y: 0, angle: 0 };
5062 const scene = makeScene();
5163 const gun = makeGun();
5264 const beeper = makeBeeper();
@@ -66,6 +78,7 @@ function connect() {
6678 if (data.t !== "s") return;
6779 const first = snap === null;
6880 snap = data;
81 snapAt = performance.now();
6982 if (first && mode === "fp") yaw = data.me.angle;
7083 gunSnapshot(gun, data);
7184 paintHud(data);
@@ -206,11 +219,10 @@ view.addEventListener("pointerup", endPointer);
206219 view.addEventListener("pointercancel", endPointer);
207220 view.addEventListener("contextmenu", (event) => event.preventDefault());
208221
209 // Twenty times a second the server hears what this person is doing. In the
210 // first-person view the keys are read against the way you are facing; in the
211 // top-down view they are the compass, the way they always were.
212 setInterval(() => {
213 if (!snap || !map) return;
222 // What this person is doing, in the world's terms. In the first-person view
223 // the keys are read against the way you are facing; in the top-down view they
224 // are the compass, the way they always were.
225 function readIntent() {
214226 let ax = 0;
215227 let ay = 0;
216228 if (keys.has("KeyW")) ay -= 1;
@@ -221,31 +233,31 @@ setInterval(() => {
221233 ax = stick.mx;
222234 ay = stick.my;
223235 }
224 let mx = ax;
225 let my = ay;
226 let angle;
236 intent.mx = ax;
237 intent.my = ay;
227238 if (mode === "fp") {
228 if (keys.has("ArrowLeft")) yaw -= 0.045;
229 if (keys.has("ArrowRight")) yaw += 0.045;
230 angle = yaw;
239 intent.angle = yaw;
231240 moveVector(ax, ay, yaw, walk);
232 mx = walk.x;
233 my = walk.y;
241 intent.mx = walk.x;
242 intent.my = walk.y;
234243 } else {
235244 const aim = aimTouch || pointer;
236 const me = worldToScreen(snap.me);
237 angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
238 yaw = angle;
245 const me = worldToScreen(drawnMe.id ? drawnMe : snap.me);
246 intent.angle = aim ? Math.atan2(aim.y - me.y, aim.x - me.x) : snap.me.angle;
247 yaw = intent.angle;
239248 }
240 send({
241 t: "i",
242 mx,
243 my,
244 a: angle,
245 f: firing || keys.has("Space"),
246 u: keys.has("KeyF"),
247 r: keys.has("KeyR"),
248 });
249 intent.fire = firing || keys.has("Space");
250 intent.use = keys.has("KeyF");
251 intent.reload = keys.has("KeyR");
252 return intent;
253 }
254
255 // Twenty times a second the server hears it. Intent only — a direction, an
256 // angle, a held key — never a position: the guess below is for drawing.
257 setInterval(() => {
258 if (!snap || !map) return;
259 const now = readIntent();
260 send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload });
249261 }, 50);
250262
251263 // ------------------------------------------------------------------ drawing
@@ -275,7 +287,49 @@ function smooth(id, x, y, angle) {
275287 return now;
276288 }
277289
278 const look = (p) => smooth(p.id, p.x, p.y, p.angle);
290 /**
291 * Everybody else is smoothed toward the last position the server sent.
292 * You are not: your own body is the one this browser can run forward
293 * honestly, and smoothing it is what dragged the camera backwards.
294 */
295 const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : smooth(p.id, p.x, p.y, p.angle));
296
297 /**
298 * Your own movement is only guessed at while the round is live and you are
299 * alive. The buy phase pens everybody in their own spawn — a rule of the
300 * match, not of the map — and a guess that does not know it would spend the
301 * phase being pulled back through the door.
302 */
303 const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive;
304
305 /** The server's answer, caught up to now, then this frame of your own keys. */
306 function predict(dt, now) {
307 if (!predicting()) {
308 local.ready = false;
309 drawnMe.id = "";
310 return;
311 }
312 const held = readIntent();
313 const speed = moveSpeed({
314 haste: snap.me.haste > 0,
315 slow: snap.me.slow > 0,
316 stopped: snap.me.planting > 0 || snap.me.defusing,
317 heavy: snap.me.weapon === "operator",
318 });
319 // The snapshot is a moment old and the keys held since have not reached
320 // it; carry it that far forward and lean the drawn body onto it.
321 const target = project(snap.me, held.mx, held.my, speed, (now - snapAt) / 1000, world);
322 reconcile(local, target, dt);
323 advance(local, held.mx, held.my, speed, dt, world);
324 holdBack(local, target, held.mx, held.my);
325 // A lean across a corner could put the drawn body in a wall; the server's
326 // answer is never wrong, so take it rather than draw from inside one.
327 if (insideWall(local, world)) anchor(local, snap.me);
328 drawnMe.id = snap.me.id;
329 drawnMe.x = local.x;
330 drawnMe.y = local.y;
331 drawnMe.angle = mode === "fp" ? yaw : snap.me.angle;
332 }
279333
280334 function frame(now) {
281335 requestAnimationFrame(frame);
@@ -289,6 +343,11 @@ function frame(now) {
289343 drawTopdown(ctx, map, null, look, { x: 0, y: 0, w: view.width, h: view.height });
290344 return;
291345 }
346 // Turning with the arrow keys belongs to the frame, at a rate a second,
347 // rather than to whatever the input timer happens to do.
348 if (mode === "fp" && keys.has("ArrowLeft")) yaw -= 0.9 * dt;
349 if (mode === "fp" && keys.has("ArrowRight")) yaw += 0.9 * dt;
350 predict(dt, now);
292351 beeper.update(snap.spike.planted, snap.spike.fuse, now / 1000);
293352 const eye = look(snap.me);
294353 const facing = mode === "fp" ? yaw : eye.angle;
@@ -606,6 +665,9 @@ Promise.all([fetch("/map.json").then((r) => r.json()), fetch("/agents.json").the
606665 const cols = rows[0].length;
607666 solid = (col, row) =>
608667 col < 0 || row < 0 || row >= rows.length || col >= cols || rows[row][col] === "#";
668 // The same grid the server collides against, so a guess about your own
669 // movement lands where the server's answer is about to.
670 world = { solid, tile: map.tile, r: BODY_R };
609671 $("stage").dataset.mode = mode;
610672 fillHelp();
611673 resize();
↑↓ navigate ↵ open esc close

Keyboard shortcuts