Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

The solver moves behind the door: src/engine/physics.ts, and Counter-Strike's numbers become src/client/movement.ts #140

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/123-the-solver-moves-behind-the-door-src-eng → main 2 commits fork

  • Merged. torque/123-the-solver-moves-behind-the-door-src-eng was merged into main as 339dc74.

Review

Sign in to review this change.
27 files changed +389 −250
README.md +13 −6
@@ -159,7 +159,12 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
159159
160160 - `src/engine/` — the part a second game could use, and the rule is that it never
161161 imports the game: `math` (vectors, matrices, angles, a seeded rng), `trace`
162 (rays against boxes, and the `Box` they are traced against), `grid` (a grid of
162 (rays against boxes, and the `Box` they are traced against), `physics` (the
163 Quake-lineage mover: friction, a capped ground acceleration, the air
164 acceleration that makes strafe-jumping work, walls, steps and the crouch — it
165 reads no tuned number of its own, because a `MoveTuning` and a `Hull` arrive as
166 arguments on every call, so the feel of a game is the pair it hands in),
167 `grid` (a grid of
163168 cells of any type, the cell-to-world arithmetic, greedy meshing and A* with no
164169 corner-cutting — which cells are solid or walkable arrive as predicates, so it
165170 knows nothing about de_forge's alphabet), `mesh` (a box builder), `texture`
@@ -214,7 +219,9 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
214219 `length`, `length2d`, `dist`, `dist2d`, `normalize`, `lerp`, `clamp`, `rad`,
215220 `deg`, `angleDelta`, `angleVectors`, `mat4`, `multiply`, `perspective`,
216221 `viewMatrix`, `modelMatrix`, `rng`, `Vec3` and `Mat4`; from `trace` `rayBox`,
217 `traceWorld`, `visible`, `segmentLength`, `Box` and `Hit`; from `grid`
222 `traceWorld`, `visible`, `segmentLength`, `Box` and `Hit`; from `physics` `move`,
223 `accelerate`, `airAccelerate`, `groundUnder`, `stuck`, `eyeHeight`,
224 `hullHeight`, `Body`, `Wish`, `MoveTuning` and `Hull`; from `grid`
218225 `inside`, `cellAt`, `worldOf`, `colOf`, `rowOf`, `mergeSolid`, `findPath` and
219226 `Grid`; from `mesh` `builder`, `addBox`, `finish`, `quadMesh`, `Builder` and
220227 `Mesh`; from `texture` `hash`, `noise`, `fbm`, `paintAtlas` and `Shader`; from
@@ -255,10 +262,10 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
255262 `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell
256263 alphabet and its spawns and sites, laid over the engine's grid, exported as
257264 `DE_FORGE` — one value of `Level` and the only one that ships),
258 `physics` (the Quake-lineage mover, which reads no number out of its own
259 module scope: every function takes what it is tuned by as a required
260 argument — subject, situation, tuning, hull — and `MOVE` and `HULL` are the
261 Counter-Strike answers its callers hand in), `weapons`, `combat`, `match`
265 `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`
266 and `fallDamage`, handed to the engine's mover on every call — the solver
267 itself went behind the door in #123 and holds none of them), `weapons`,
268 `combat`, `match`
262269 (the rules),
263270 `world` (the simulation),
264271 `bots`, `observe`, `action`, `weights`, `models`, `materials`,
src/client/bundle.test.ts +11
@@ -208,6 +208,17 @@ test("the shooter bundles the engine's policy network, because its bots run it",
208208 expect(code).not.toContain("btoa");
209209 });
210210
211 test("the shooter bundles the engine's mover, tuned by its own numbers", () => {
212 // The other end of `src/range/bundle.test.ts`'s second claim. The solver is
213 // the engine's since #123 and the numbers are still this game's: the fields
214 // are here because `src/client/movement.ts` writes them, and so is the jump
215 // velocity itself, which no engine module knows.
216 const code = readFileSync(bundlePath("app"), "utf8");
217 for (const field of ["airSpeedCap", "duckRate", "jumpVelocity", "stopSpeed"])
218 expect(code).toContain(field);
219 expect(code).toContain("301.993");
220 });
221
211222 test("the stub page leaves nothing of itself behind", () => {
212223 // Every boot above undid its install, so no other test inherits a document —
213224 // nor a `fetch` that answers every URL with a leaderboard.
src/client/combat.ts +2 −2
@@ -6,7 +6,7 @@
66
77 import type { Box } from "./level.ts";
88 import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts";
9 import { HULL } from "./physics.ts";
9 import { CS_HULL } from "./movement.ts";
1010 import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts";
1111
1212 export type Actor = {
@@ -32,7 +32,7 @@ const PARTS: [Hitgroup, number, number, number][] = [
3232 ];
3333
3434 export function hitboxes(a: Actor): Part[] {
35 const height = HULL.stand + (HULL.duck - HULL.stand) * a.duckAmount;
35 const height = CS_HULL.stand + (CS_HULL.duck - CS_HULL.stand) * a.duckAmount;
3636 return PARTS.map(([group, lo, hi, w]) => ({
3737 group,
3838 min: [a.pos[0] - w, a.pos[1] - w, a.pos[2] + height * lo] as Vec3,
src/client/hud.ts +2 −2
@@ -11,7 +11,7 @@
1111 */
1212
1313 import { clamp } from "../engine/index.ts";
14 import { MOVE } from "./physics.ts";
14 import { CS_MOVE } from "./movement.ts";
1515 import { inaccuracy, WEAPONS } from "./weapons.ts";
1616 import { currentWeapon, useProgress, type World } from "./world.ts";
1717
@@ -61,7 +61,7 @@ export function drawHud(
6161 if (me.alive) {
6262 const speed = Math.hypot(me.vel[0], me.vel[1]);
6363 const spread = inaccuracy(weapon, {
64 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
64 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
6565 inAir: !me.onGround,
6666 ducking: me.duckAmount > 0.5,
6767 scoped: me.scoped,
src/client/models.test.ts +8 −8
@@ -7,10 +7,10 @@
77 * there is no vertex order in this file and no float pinned to fifteen digits.
88 *
99 * The one geometry a frame depends on is the player's height: `scene.ts`
10 * squashes a ducking player by `hullHeight(p) / HULL.stand` about their feet,
11 * so the mesh has to stand with its feet at z 0 and its head at `HULL.stand`.
10 * squashes a ducking player by `hullHeight(p) / CS_HULL.stand` about their feet,
11 * so the mesh has to stand with its feet at z 0 and its head at `CS_HULL.stand`.
1212 * It did not: the head reached 73.5, and `models.ts` now takes the top from
13 * `HULL` instead of from a literal.
13 * `CS_HULL` instead of from a literal.
1414 */
1515
1616 import { expect, test } from "bun:test";
@@ -21,7 +21,7 @@ import type { Box } from "./level.ts";
2121 import { DE_FORGE } from "./map.ts";
2222 import { LAYERS } from "./materials.ts";
2323 import { bombMesh, levelMesh, meshFor, playerMesh, viewModelMesh } from "./models.ts";
24 import { HULL } from "./physics.ts";
24 import { CS_HULL } from "./movement.ts";
2525 import { WEAPONS } from "./weapons.ts";
2626
2727 /**
@@ -106,10 +106,10 @@ test("the player stands on the floor at exactly the standing hull height", () =>
106106 // `scene.ts` scales a ducking player from the floor, so the feet are the
107107 // origin and the top is the hull: a duck squash then lands on the duck hull.
108108 expect(min[2]).toBe(0);
109 expect(max[2]).toBe(HULL.stand);
110 expect(max[2] * (HULL.duck / HULL.stand)).toBeCloseTo(HULL.duck, 10);
109 expect(max[2]).toBe(CS_HULL.stand);
110 expect(max[2] * (CS_HULL.duck / CS_HULL.stand)).toBeCloseTo(CS_HULL.duck, 10);
111111 // A body, not a tower: it stays inside the hull across, give or take the gun.
112 expect(max[1] - min[1]).toBeLessThanOrEqual(HULL.radius * 2 + 4);
112 expect(max[1] - min[1]).toBeLessThanOrEqual(CS_HULL.radius * 2 + 4);
113113 });
114114
115115 test("the bomb sits on the ground and lights up on a layer of its own", () => {
@@ -118,7 +118,7 @@ test("the bomb sits on the ground and lights up on a layer of its own", () => {
118118 const { min, max } = bounds(mesh);
119119 expect(min[2]).toBe(0);
120120 expect(max[2]).toBeGreaterThan(0);
121 expect(max[2]).toBeLessThan(HULL.duck);
121 expect(max[2]).toBeLessThan(CS_HULL.duck);
122122 // The light on top is the last layer of the atlas, and the case is not it.
123123 expect(new Set(mesh.layer).size).toBe(2);
124124 expect([...mesh.layer]).toContain(LAYERS - 1);
src/client/models.ts +5 −5
@@ -6,7 +6,7 @@
66
77 import { addBox, builder, finish, type Builder, type Mesh } from "../engine/index.ts";
88 import type { Box, Level } from "./level.ts";
9 import { HULL } from "./physics.ts";
9 import { CS_HULL } from "./movement.ts";
1010
1111 /** The level, as one buffer. Fifty-odd boxes, three hundred quads, one draw. */
1212 export function levelMesh(boxes: Box[]): Mesh {
@@ -22,11 +22,11 @@ const cube = (b: Builder, cx: number, cy: number, cz: number, sx: number, sy: nu
2222 * A player: legs, torso, arms, head, and the gun they are holding, built
2323 * standing at the origin facing +X so a yaw rotation is all a draw needs.
2424 *
25 * Feet at z 0 and the crown of the head at `HULL.stand`, both exactly: a
25 * Feet at z 0 and the crown of the head at `CS_HULL.stand`, both exactly: a
2626 * ducking player is drawn by scaling this from the floor by
27 * `hullHeight(p) / HULL.stand`, so a model taller than the hull is a head that
27 * `hullHeight(p) / CS_HULL.stand`, so a model taller than the hull is a head that
2828 * pokes through a vent you could walk under. The head takes its height from
29 * `HULL` rather than a literal, which is why it cannot drift again.
29 * `CS_HULL` rather than a literal, which is why it cannot drift again.
3030 */
3131 export function playerMesh(): Mesh {
3232 const b = builder();
@@ -37,7 +37,7 @@ export function playerMesh(): Mesh {
3737 cube(b, 0, 0, 48, 16, 26, 26, GEAR); // torso
3838 cube(b, 2, -14, 46, 9, 9, 22, KIT); // right arm
3939 cube(b, 6, 10, 46, 9, 9, 20, KIT); // left arm
40 cube(b, 1, 0, HULL.stand - HEAD / 2, HEAD, HEAD, HEAD, SKIN); // head, crowned at the hull
40 cube(b, 1, 0, CS_HULL.stand - HEAD / 2, HEAD, HEAD, HEAD, SKIN); // head, crowned at the hull
4141 cube(b, 14, -6, 46, 26, 5, 7, GEAR); // the gun in their hands
4242 return finish(b);
4343 }
src/client/physics.test.ts → src/client/movement.test.ts renamed +58 −47
@@ -1,10 +1,21 @@
1 /**
2 * Counter-Strike's numbers, held to what a player feels: 250 u/s on the flat, a
3 * jump that clears 54 units, a 30 u/s air cap, an 18-unit step, and the crouch
4 * that shuts in a fifth of a second. The solver these run through is the
5 * engine's `physics`, which has none of those numbers in it; this file is the
6 * game's, because every assertion below is about the pair `src/client/
7 * movement.ts` hands in.
8 *
9 * The second half runs the same functions on a profile that shares none of the
10 * values under test, which is what says the tuning is really an argument.
11 */
12
113 import { expect, test } from "bun:test";
2 import type { Box } from "./level.ts";
3 import type { Vec3 } from "../engine/index.ts";
414 import {
5 accelerate, airAccelerate, eyeHeight, fallDamage, HULL, hullHeight, MOVE, move,
6 type Body, type Hull, type MoveTuning, type Wish,
7 } from "./physics.ts";
15 accelerate, airAccelerate, eyeHeight, hullHeight, move,
16 type Body, type Box, type Hull, type MoveTuning, type Vec3, type Wish,
17 } from "../engine/index.ts";
18 import { CS_HULL, CS_MOVE, fallDamage } from "./movement.ts";
819
920 const ground: Box = { min: [-1000, -1000, -32], max: [1000, 1000, 0], mat: 0 };
1021
@@ -17,7 +28,7 @@ const body = (pos: Vec3 = [0, 0, 0]): Body => ({
1728 });
1829
1930 const wish = (over: Partial<Wish> = {}): Wish => ({
20 forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: MOVE.maxSpeed, ...over,
31 forward: 0, side: 0, yaw: 0, jump: false, duck: false, walk: false, maxSpeed: CS_MOVE.maxSpeed, ...over,
2132 });
2233
2334 const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning, hull: Hull): void => {
@@ -26,28 +37,28 @@ const run = (b: Body, w: Wish, seconds: number, boxes: Box[], tuning: MoveTuning
2637
2738 test("running forwards tops out at the weapon's carry speed", () => {
2839 const b = body();
29 run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL);
30 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 0);
40 run(b, wish({ forward: 1 }), 3, [ground], CS_MOVE, CS_HULL);
41 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 0);
3142 expect(b.pos[0]).toBeGreaterThan(500);
3243 });
3344
3445 test("letting go stops you, but not instantly", () => {
3546 const b = body();
36 run(b, wish({ forward: 1 }), 2, [ground], MOVE, HULL);
37 move(b, wish(), 1 / 64, [ground], MOVE, HULL);
47 run(b, wish({ forward: 1 }), 2, [ground], CS_MOVE, CS_HULL);
48 move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL);
3849 const afterOneTick = Math.hypot(b.vel[0], b.vel[1]);
3950 expect(afterOneTick).toBeGreaterThan(200);
40 run(b, wish(), 1, [ground], MOVE, HULL);
51 run(b, wish(), 1, [ground], CS_MOVE, CS_HULL);
4152 expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(1);
4253 });
4354
4455 test("air acceleration is capped at 30 u/s along the wish direction", () => {
4556 const vel: Vec3 = [250, 0, 0];
46 airAccelerate(vel, [1, 0, 0], 250, 1, MOVE);
57 airAccelerate(vel, [1, 0, 0], 250, 1, CS_MOVE);
4758 expect(vel[0]).toBeCloseTo(250, 5); // already faster than the air cap: nothing added
4859 const sideways: Vec3 = [250, 0, 0];
49 airAccelerate(sideways, [0, 1, 0], 250, 1, MOVE);
50 expect(sideways[1]).toBeCloseTo(MOVE.airSpeedCap, 5); // across it: the full cap
60 airAccelerate(sideways, [0, 1, 0], 250, 1, CS_MOVE);
61 expect(sideways[1]).toBeCloseTo(CS_MOVE.airSpeedCap, 5); // across it: the full cap
5162 });
5263
5364 test("ground acceleration never overshoots the wish speed", () => {
@@ -59,8 +70,8 @@ test("ground acceleration never overshoots the wish speed", () => {
5970 test("a wall stops you and you keep sliding along it", () => {
6071 const wall: Box = { min: [200, -1000, 0], max: [264, 1000, 384], mat: 1 };
6172 const b = body();
62 run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall], MOVE, HULL);
63 expect(b.pos[0]).toBeLessThanOrEqual(200 - HULL.radius + 0.01);
73 run(b, wish({ forward: 1, side: -1 }), 3, [ground, wall], CS_MOVE, CS_HULL);
74 expect(b.pos[0]).toBeLessThanOrEqual(200 - CS_HULL.radius + 0.01);
6475 expect(Math.abs(b.pos[1])).toBeGreaterThan(200); // still moving along the wall
6576 });
6677
@@ -68,21 +79,21 @@ test("you step onto a 16-unit ledge and not onto a 40-unit one", () => {
6879 const low: Box = { min: [100, -100, 0], max: [400, 100, 16], mat: 2 };
6980 const high: Box = { min: [100, -100, 0], max: [400, 100, 40], mat: 2 };
7081 const stepper = body([0, 0, 0]);
71 run(stepper, wish({ forward: 1 }), 1, [ground, low], MOVE, HULL);
82 run(stepper, wish({ forward: 1 }), 1, [ground, low], CS_MOVE, CS_HULL);
7283 expect(stepper.pos[2]).toBeCloseTo(16, 1);
7384 expect(stepper.pos[0]).toBeGreaterThan(150);
7485 const blocked = body([0, 0, 0]);
75 run(blocked, wish({ forward: 1 }), 1, [ground, high], MOVE, HULL);
86 run(blocked, wish({ forward: 1 }), 1, [ground, high], CS_MOVE, CS_HULL);
7687 expect(blocked.pos[2]).toBe(0);
7788 expect(blocked.pos[0]).toBeLessThan(100);
7889 });
7990
8091 test("a jump leaves the ground and gravity brings it back", () => {
8192 const b = body();
82 move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL);
93 move(b, wish({ jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL);
8394 expect(b.onGround).toBe(false);
8495 expect(b.vel[2]).toBeGreaterThan(250);
85 run(b, wish(), 2, [ground], MOVE, HULL);
96 run(b, wish(), 2, [ground], CS_MOVE, CS_HULL);
8697 expect(b.onGround).toBe(true);
8798 expect(b.pos[2]).toBeCloseTo(0, 1);
8899 });
@@ -90,9 +101,9 @@ test("a jump leaves the ground and gravity brings it back", () => {
90101 test("a jump clears 54 units, the height CS players count on", () => {
91102 const b = body();
92103 let peak = 0;
93 move(b, wish({ jump: true }), 1 / 64, [ground], MOVE, HULL);
104 move(b, wish({ jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL);
94105 for (let t = 0; t < 1; t += 1 / 64) {
95 move(b, wish(), 1 / 64, [ground], MOVE, HULL);
106 move(b, wish(), 1 / 64, [ground], CS_MOVE, CS_HULL);
96107 peak = Math.max(peak, b.pos[2]);
97108 }
98109 expect(peak).toBeGreaterThan(54);
@@ -101,31 +112,31 @@ test("a jump clears 54 units, the height CS players count on", () => {
101112
102113 test("crouching shrinks the hull and slows you down", () => {
103114 const b = body();
104 run(b, wish({ forward: 1, duck: true }), 2, [ground], MOVE, HULL);
115 run(b, wish({ forward: 1, duck: true }), 2, [ground], CS_MOVE, CS_HULL);
105116 expect(b.duckAmount).toBeCloseTo(1, 2);
106 expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(MOVE.duckSpeed + 5);
117 expect(Math.hypot(b.vel[0], b.vel[1])).toBeLessThan(CS_MOVE.duckSpeed + 5);
107118 });
108119
109120 test("you cannot stand up under a low ceiling", () => {
110121 const ceiling: Box = { min: [-100, -100, 60], max: [100, 100, 200], mat: 1 };
111122 const b = body();
112123 b.duckAmount = 1;
113 run(b, wish({ duck: true }), 1, [ground, ceiling], MOVE, HULL);
114 run(b, wish({ duck: false }), 1, [ground, ceiling], MOVE, HULL);
124 run(b, wish({ duck: true }), 1, [ground, ceiling], CS_MOVE, CS_HULL);
125 run(b, wish({ duck: false }), 1, [ground, ceiling], CS_MOVE, CS_HULL);
115126 expect(b.duckAmount).toBeGreaterThan(0.2);
116127 });
117128
118129 test("strafing in the air gains speed a straight line cannot", () => {
119130 // Hold strafe-left and turn left through the jump: the classic gain.
120131 const b = body();
121 b.vel = [MOVE.maxSpeed, 0, 0];
122 move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground], MOVE, HULL);
132 b.vel = [CS_MOVE.maxSpeed, 0, 0];
133 move(b, wish({ forward: 1, jump: true }), 1 / 64, [ground], CS_MOVE, CS_HULL);
123134 let yaw = 0;
124135 while (!b.onGround) {
125136 yaw += 0.55;
126 move(b, wish({ side: -1, yaw }), 1 / 128, [ground], MOVE, HULL);
137 move(b, wish({ side: -1, yaw }), 1 / 128, [ground], CS_MOVE, CS_HULL);
127138 }
128 expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(MOVE.maxSpeed + 2);
139 expect(Math.hypot(b.vel[0], b.vel[1])).toBeGreaterThan(CS_MOVE.maxSpeed + 2);
129140 });
130141
131142 test("fall damage starts only after a long drop", () => {
@@ -144,7 +155,7 @@ test("fall damage starts only after a long drop", () => {
144155
145156 /** Half the gravity, a higher jump, twice the step, a lazier crouch. */
146157 const MOON: MoveTuning = {
147 ...MOVE,
158 ...CS_MOVE,
148159 gravity: 400, jumpVelocity: 600, maxSpeed: 500, duckSpeed: 200, walkSpeed: 250,
149160 airAccelerate: 6, airSpeedCap: 60, stepSize: 36, duckRate: 2.5,
150161 };
@@ -170,26 +181,26 @@ const jump = (tuning: MoveTuning, hull: Hull): { peak: number; ticks: number } =
170181 test("the apex and the hang time are the profile's gravity and jump velocity", () => {
171182 // v²/2g and 2v/g, by hand: 301.993² / 1600 ≈ 57.0 units and 0.755 s under CS,
172183 // 600² / 800 = 450 units and 3 s under half gravity and a 600 u/s jump.
173 const cs = jump(MOVE, HULL);
184 const cs = jump(CS_MOVE, CS_HULL);
174185 const moon = jump(MOON, GIANT);
175 expect(cs.peak).toBeCloseTo(MOVE.jumpVelocity ** 2 / (2 * MOVE.gravity), 1);
186 expect(cs.peak).toBeCloseTo(CS_MOVE.jumpVelocity ** 2 / (2 * CS_MOVE.gravity), 1);
176187 expect(moon.peak).toBeCloseTo(MOON.jumpVelocity ** 2 / (2 * MOON.gravity), 1);
177188 // Hang time to within a tick and a half of 2v/g: 48 ticks against 192.
178189 const hang = (t: MoveTuning): number => ((2 * t.jumpVelocity) / t.gravity) * 64;
179 expect(Math.abs(cs.ticks - hang(MOVE))).toBeLessThan(1.5);
190 expect(Math.abs(cs.ticks - hang(CS_MOVE))).toBeLessThan(1.5);
180191 expect(Math.abs(moon.ticks - hang(MOON))).toBeLessThan(1.5);
181192 });
182193
183194 test("the walk, duck and run speeds are the profile's own", () => {
184195 const top = (w: Partial<Wish>, tuning: MoveTuning): number => {
185196 const b = body();
186 run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, HULL);
197 run(b, { ...wish(w), maxSpeed: tuning.maxSpeed }, 4, [ground], tuning, CS_HULL);
187198 return Math.hypot(b.vel[0], b.vel[1]);
188199 };
189200 expect(top({ forward: 1 }, MOON)).toBeCloseTo(MOON.maxSpeed, 5);
190201 expect(top({ forward: 1, walk: true }, MOON)).toBeCloseTo(MOON.walkSpeed, 5);
191202 expect(top({ forward: 1, duck: true }, MOON)).toBeCloseTo(MOON.duckSpeed, 5);
192 expect(top({ forward: 1, walk: true }, MOVE)).toBeCloseTo(MOVE.walkSpeed, 5);
203 expect(top({ forward: 1, walk: true }, CS_MOVE)).toBeCloseTo(CS_MOVE.walkSpeed, 5);
193204 });
194205
195206 test("the crouch closes at the rate the profile names", () => {
@@ -198,7 +209,7 @@ test("the crouch closes at the rate the profile names", () => {
198209 const lazy = body(), quick = body();
199210 for (let i = 0; i < 20; i++) {
200211 move(lazy, wish({ duck: true }), 1 / 64, [ground], MOON, GIANT);
201 move(quick, wish({ duck: true }), 1 / 64, [ground], MOVE, HULL);
212 move(quick, wish({ duck: true }), 1 / 64, [ground], CS_MOVE, CS_HULL);
202213 }
203214 expect(lazy.duckAmount).toBeCloseTo((20 / 64) * MOON.duckRate, 10);
204215 expect(quick.duckAmount).toBe(1);
@@ -221,7 +232,7 @@ test("a 36-unit step climbs a ledge an 18-unit step refuses", () => {
221232 const ledge: Box = { min: [100, -100, 0], max: [400, 100, 30], mat: 2 };
222233 const tall = body(), short = body();
223234 run(tall, { ...wish({ forward: 1 }), maxSpeed: MOON.maxSpeed }, 1, [ground, ledge], MOON, GIANT);
224 run(short, wish({ forward: 1 }), 1, [ground, ledge], MOVE, HULL);
235 run(short, wish({ forward: 1 }), 1, [ground, ledge], CS_MOVE, CS_HULL);
225236 expect(tall.pos[2]).toBe(30);
226237 expect(short.pos[2]).toBe(0);
227238 expect(short.pos[0]).toBeLessThan(100);
@@ -241,24 +252,24 @@ test("the air cap and the air acceleration come from the tuning", () => {
241252 test("acceleration and friction settle where the tuning says they do", () => {
242253 // Running holds where what acceleration adds is what friction takes:
243254 // accel × wishSpeed = speed × friction, so 2.8 × 250 / 5.2 ≈ 134.6 u/s.
244 const half: MoveTuning = { ...MOVE, accelerate: 2.8 };
255 const half: MoveTuning = { ...CS_MOVE, accelerate: 2.8 };
245256 const b = body();
246 run(b, wish({ forward: 1 }), 3, [ground], MOVE, HULL);
257 run(b, wish({ forward: 1 }), 3, [ground], CS_MOVE, CS_HULL);
247258 const slower = body();
248 run(slower, wish({ forward: 1 }), 3, [ground], half, HULL);
249 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(MOVE.maxSpeed, 5);
259 run(slower, wish({ forward: 1 }), 3, [ground], half, CS_HULL);
260 expect(Math.hypot(b.vel[0], b.vel[1])).toBeCloseTo(CS_MOVE.maxSpeed, 5);
250261 expect(Math.hypot(slower.vel[0], slower.vel[1]))
251 .toBeCloseTo((half.accelerate * MOVE.maxSpeed) / half.friction, 2);
262 .toBeCloseTo((half.accelerate * CS_MOVE.maxSpeed) / half.friction, 2);
252263
253264 // And a drifting body: no friction keeps every unit of its speed, while a
254265 // stop speed five times CS's takes all of it inside three ticks.
255266 const drift = (tuning: MoveTuning): number => {
256267 const d = body();
257268 d.vel = [40, 0, 0];
258 for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, HULL);
269 for (let i = 0; i < 3; i++) move(d, wish(), 1 / 64, [ground], tuning, CS_HULL);
259270 return Math.hypot(d.vel[0], d.vel[1]);
260271 };
261 expect(drift({ ...MOVE, friction: 0 })).toBe(40);
262 expect(drift({ ...MOVE, stopSpeed: 400 })).toBe(0);
263 expect(drift(MOVE)).toBeCloseTo(20.5, 5);
272 expect(drift({ ...CS_MOVE, friction: 0 })).toBe(40);
273 expect(drift({ ...CS_MOVE, stopSpeed: 400 })).toBe(0);
274 expect(drift(CS_MOVE)).toBeCloseTo(20.5, 5);
264275 });
src/client/movement.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * How Counter-Strike moves: the numbers the engine's solver is handed, and the
3 * one curve that is this game's rather than the lineage's.
4 *
5 * The solver itself is `src/engine/physics.ts` — friction, acceleration, the air
6 * cap, the step, the crouch — and it holds not one of the values below. Every
7 * call in this game passes `CS_MOVE` and `CS_HULL`, so a reader who wants to
8 * know how fast a player runs reads this file and no other, and a second game on
9 * the same arithmetic writes its own pair instead of editing these.
10 *
11 * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity,
12 * a 32×32×72 hull, an 18-unit step, and a jump that clears 54 of them.
13 */
14
15 import type { Hull, MoveTuning } from "../engine/index.ts";
16
17 export const CS_HULL: Hull = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 };
18
19 export const CS_MOVE: MoveTuning = {
20 gravity: 800,
21 maxSpeed: 250,
22 duckSpeed: 85,
23 walkSpeed: 130,
24 accelerate: 5.6,
25 airAccelerate: 12,
26 airSpeedCap: 30,
27 friction: 5.2,
28 stopSpeed: 80,
29 jumpVelocity: 301.993,
30 stepSize: 18,
31 duckRate: 5,
32 };
33
34 /**
35 * Fall damage, CS-style: nothing under ~580 u/s, then it bites. This is a curve
36 * this game chose and not a consequence of the solver, which is why it is here
37 * and not behind the door — a landing hurts because of a rule, not because of
38 * arithmetic.
39 */
40 export const fallDamage = (impactSpeed: number): number =>
41 impactSpeed < 580 ? 0 : Math.round((impactSpeed - 580) * 0.0888 * 100) / 100;
src/client/observe.ts +2 −2
@@ -14,7 +14,7 @@
1414
1515 import { colOf, rowOf, walkable, type Level } from "./level.ts";
1616 import { angleDelta, clamp, dist, type Vec3 } from "../engine/index.ts";
17 import { HULL } from "./physics.ts";
17 import { CS_HULL } from "./movement.ts";
1818 import { inaccuracy, WEAPONS } from "./weapons.ts";
1919 import type { Player, World } from "./world.ts";
2020 import { aimAt, bodyCentre, currentWeapon, eyePos } from "./world.ts";
@@ -69,7 +69,7 @@ export function observe(
6969 theirHealth = target.health / 100;
7070 theirSpeed = Math.hypot(target.vel[0], target.vel[1]) / 250;
7171 // How squarely the enemy is looking back: 1 is down the barrel, -1 is away.
72 const back = aimAt(target, [bot.pos[0], bot.pos[1], bot.pos[2] + HULL.stand * 0.72]);
72 const back = aimAt(target, [bot.pos[0], bot.pos[1], bot.pos[2] + CS_HULL.stand * 0.72]);
7373 theirFacing = Math.cos(angleDelta(target.yaw, back.yaw) * rad);
7474 }
7575
src/client/scene.test.ts +2 −2
@@ -14,7 +14,7 @@ import { expect, test } from "bun:test";
1414 import { existsSync, readFileSync } from "node:fs";
1515 import { join } from "node:path";
1616 import type { Camera } from "../engine/index.ts";
17 import { HULL } from "./physics.ts";
17 import { CS_HULL } from "./movement.ts";
1818 import { buildScene, createScene, LINE_CAPACITY, SKY } from "./scene.ts";
1919 import { DE_FORGE } from "./map.ts";
2020 import { SHORT } from "./match.ts";
@@ -70,7 +70,7 @@ test("a duck squashes a player by the hull it has, a standing one by nothing", (
7070 other.duckAmount = 1;
7171 buildScene(w, camera(), scene);
7272 const ducking = scene.instances.slice(1, scene.instanceCount).find((i) => i.pos[0] === other.pos[0]);
73 expect(ducking?.scale).toBeCloseTo(HULL.duck / HULL.stand, 10);
73 expect(ducking?.scale).toBeCloseTo(CS_HULL.duck / CS_HULL.stand, 10);
7474 expect(ducking?.scale).toBeCloseTo(54 / 72, 10);
7575 });
7676
src/client/scene.ts +3 −3
@@ -18,10 +18,10 @@
1818 */
1919
2020 import {
21 clamp, createScene as createPooledScene, nextInstance, pushLine, resetScene,
21 clamp, createScene as createPooledScene, hullHeight, nextInstance, pushLine, resetScene,
2222 type Camera, type Scene as Frame, type Vec3,
2323 } from "../engine/index.ts";
24 import { HULL, hullHeight } from "./physics.ts";
24 import { CS_HULL } from "./movement.ts";
2525 import { WEAPONS } from "./weapons.ts";
2626 import type { World } from "./world.ts";
2727
@@ -131,7 +131,7 @@ export function buildScene(world: World, camera: Camera, into: Scene): Scene {
131131 for (const p of world.players) {
132132 // The eye is inside your own head: drawing it would fill the screen.
133133 if (!p.alive || p === world.me) continue;
134 place(into, "player", p.pos, p.yaw, hullHeight(p, HULL) / HULL.stand, TEAM_TINT[p.team] ?? WHITE);
134 place(into, "player", p.pos, p.yaw, hullHeight(p, CS_HULL) / CS_HULL.stand, TEAM_TINT[p.team] ?? WHITE);
135135 }
136136 if (world.bomb.state === "planted" || world.bomb.state === "dropped")
137137 place(into, "bomb", world.bomb.pos, 0, 1, WHITE);
src/client/world.ts +10 −7
@@ -9,9 +9,12 @@
99
1010 import { applyHit, traceBullet, type Actor } from "./combat.ts";
1111 import { siteAt, type Level } from "./level.ts";
12 import { angleVectors, angleDelta, clamp, dist, normalize, rng, sub, visible, type Vec3 } from "../engine/index.ts";
12 import {
13 angleVectors, angleDelta, clamp, dist, eyeHeight, hullHeight, move, normalize, rng, sub, visible,
14 type Body, type Vec3, type Wish,
15 } from "../engine/index.ts";
1316 import * as rules from "./match.ts";
14 import { eyeHeight, hullHeight, move, type Body, type Wish, HULL, MOVE } from "./physics.ts";
17 import { CS_HULL, CS_MOVE } from "./movement.ts";
1518 import {
1619 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
1720 type WeaponId, type Weapon,
@@ -172,7 +175,7 @@ export const teammates = (world: World, team: Team): Player[] => world.players.f
172175 export const alive = (world: World, team: Team): Player[] =>
173176 world.players.filter((p) => p.team === team && p.alive);
174177
175 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, HULL)];
178 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p, CS_HULL)];
176179
177180 function say(world: World, text: string): void {
178181 world.messages.push({ at: world.time, text });
@@ -335,7 +338,7 @@ export function shoot(world: World, p: Player): boolean {
335338
336339 const speed = Math.hypot(p.vel[0], p.vel[1]);
337340 const spread = inaccuracy(w, {
338 moving: clamp(speed / MOVE.maxSpeed, 0, 1),
341 moving: clamp(speed / CS_MOVE.maxSpeed, 0, 1),
339342 inAir: !p.onGround,
340343 ducking: p.duckAmount > 0.5,
341344 scoped: p.scoped,
@@ -466,9 +469,9 @@ export function applyInput(world: World, p: Player, input: Input, dt: number): v
466469 jump: input.jump && !frozen,
467470 duck: input.duck,
468471 walk: input.walk || p.scoped,
469 maxSpeed: Math.min(MOVE.maxSpeed, w.carrySpeed),
472 maxSpeed: Math.min(CS_MOVE.maxSpeed, w.carrySpeed),
470473 };
471 move(p, wish, dt, world.level.boxes, MOVE, HULL);
474 move(p, wish, dt, world.level.boxes, CS_MOVE, CS_HULL);
472475 finishReload(world, p);
473476 if (input.reload) reload(world, p);
474477 if (w.zoom !== undefined && input.scope !== p.scoped && world.time > p.nextShotAt - 0.05) p.scoped = input.scope;
@@ -550,4 +553,4 @@ export const aimAt = (p: Player, target: Vec3): { yaw: number; pitch: number } =
550553 };
551554
552555 /** The middle of the chest: what a bot aims at before it aims higher. */
553 export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, HULL) * 0.72];
556 export const bodyCentre = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + hullHeight(p, CS_HULL) * 0.72];
src/engine/GUIDE.md +48 −3
@@ -26,8 +26,8 @@ always the example.
2626 bare engine name here is exported, every line of every code block is a line of
2727 the file above it, and every block is a run of lines of `src/engine/example.ts`
2828 in the order that file has them. So when a group lands behind the door that this
29 page has no paragraph for — the physics of issue #3, one day — that test fails
30 until the paragraph is written. That is the point. A name arrives with a page or
29 page has no paragraph for — the mover of issue #3 was the last one — that test
30 fails until the paragraph is written. That is the point. A name arrives with a page or
3131 it does not arrive.
3232
3333 ## What a game brings
@@ -174,6 +174,51 @@ A shot in the example is one ray and one box, and it is the whole of hitscan:
174174 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world;
175175 ```
176176
177 ### physics
178
179 Walking, in the Quake lineage: ground friction, a capped ground acceleration,
180 and an air acceleration that caps only the part of your wish speed along the new
181 direction — which is why turning while you accelerate in the air makes you
182 faster, and why strafe-jumping works. The example does not use it, which is why
183 this section has no code block: it is a game with no gravity and no hull, and
184 `src/client/world.ts` is the one to read instead.
185
186 **What a game brings is three values and a list of boxes.** A `Body` is the
187 mutable subject — position, velocity, whether it is on the ground, whether it is
188 ducking and how far the crouch has closed — and `move` advances one of them by a
189 step in seconds against the `Box` list you already trace bullets through. A `Wish` is the
190 tick's intent: forward and sideways in -1..1, the yaw they are meant relative to,
191 jump, duck, walk, and the top speed this tick allows, which is where a game whose
192 heavy weapon slows you down puts that rule. A `MoveTuning` is the twelve numbers
193 that decide the feel — gravity, the run, walk and duck speeds, the ground and air
194 accelerations, the air cap, friction and stop speed, the jump velocity, the step
195 size and how fast a crouch closes — and a `Hull` is the box that collides and the
196 two eye heights it sees from.
197
198 **Nothing in here has a default and nothing is read from module scope.** Every
199 call takes the tuning and the hull, in that order, last — subject, situation,
200 world, tuning, hull. So the same solver walks two games that share no number,
201 and swapping a profile is an argument rather than an edit. The shooter's pair is
202 `movement.CS_MOVE` and `movement.CS_HULL` in `src/client/movement.ts`, and
203 `src/client/movement.test.ts` runs these functions on a second profile with half
204 the gravity and a hull twice the size to prove the arithmetic never learned the
205 first one.
206
207 The pieces under `move` are exported because they are worth using and testing on
208 their own: `accelerate` adds along a wish direction up to a wish speed and never
209 past it, `airAccelerate` is the same with the tuning's air cap over the wish
210 speed first, `stuck` asks whether a hull at a position overlaps anything,
211 `groundUnder` is the highest box top within a reach below the feet — ask that
212 rather than resolving a downward move, or a player under a low ceiling gets
213 shoved through it — and `eyeHeight` and `hullHeight` interpolate a body's crouch
214 between the hull's standing and ducked numbers, which is what a camera and a
215 model scale read.
216
217 What the mover does not know is anything about a game: no weapon, no round, no
218 level module, no fall damage. Damage from a landing is a curve a game chose —
219 `movement.fallDamage` is the shooter's — and it is on that side of the door for
220 exactly that reason.
221
177222 ### grid
178223
179224 A `Grid` of cells of any type — the cell type is your alphabet, not the
@@ -564,7 +609,7 @@ own tuning, and nothing else in this table at all.
564609 | a frame as data | `src/client/scene.ts` | `src/range/scene.ts` |
565610 | a pinned run | `src/client/replay.ts` — a script of held inputs, a round and a duel | `src/range/pin.ts` — a script of trigger pulls, one digest per drill |
566611 | no WebGL2 | a sentence in the menu, and nothing starts | the 2D horizon in `src/range/readout.ts`, and it still plays |
567 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
612 | the tuning | `src/client/movement.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
568613 | a policy's observation, heads and weights | `src/client/observe.ts`, `src/client/action.ts`, `src/client/weights.ts` | none — the range has no agent and bundles none of the net |
569614
570615 ## What the engine deliberately does not know
src/engine/boundary.test.ts +14 −9
@@ -203,7 +203,7 @@ test("the door rule is about the door and nothing else", () => {
203203 expect(reachesPastTheDoor("src/client/world.ts", "../engine/math.ts")).toMatch(/door/);
204204 expect(reachesPastTheDoor("src/rl/arena.ts", "../engine/sub/deep.ts")).toMatch(/door/);
205205 expect(reachesPastTheDoor("src/client/world.ts", "../engine/index.ts")).toBeNull();
206 expect(reachesPastTheDoor("src/client/world.ts", "./physics.ts")).toBeNull();
206 expect(reachesPastTheDoor("src/client/world.ts", "./movement.ts")).toBeNull();
207207 expect(reachesPastTheDoor("src/client/world.ts", "bun:test")).toBeNull();
208208 expect(reachesPastTheDoor("scripts/build.ts", "../src/engine/trace.ts")).toMatch(/door/);
209209 // A test may read another test's helpers; a module may not, test or not.
@@ -281,28 +281,33 @@ test("the example imports the door and nothing else, which is what it is for", (
281281 * digest — `QUANTUM`, `quantise`, `CHECKPOINT_CHARS`, `Fold`, `fold`,
282282 * `Folded`, `Pin`, `Complaint`, `compare`.
283283 *
284 * physics — `Body`, `Wish`, `MoveTuning`, `Hull`, `accelerate`,
285 * `airAccelerate`, `move`, `groundUnder`, `stuck`, `eyeHeight`, `hullHeight`.
286 *
284287 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
285288 * copy of the surface, read twice, rather than two that drift.
286289 */
287290 export const SURFACE = [
288 "Activations", "Adam", "Box", "Builder", "Burst", "Button", "ButtonOptions",
291 "Activations", "Adam", "Body", "Box", "Builder", "Burst", "Button", "ButtonOptions",
289292 "CHECKPOINT_CHARS", "Camera", "Complaint", "Controls", "Ctor", "DEFAULT_HYPER", "FakeCanvas",
290293 "FakeDocument", "FakeElement", "FakePage", "FakePaint", "FakeTarget", "FakeWindow", "Fold",
291 "Folded", "Frames", "Grads", "Grid", "Hit", "Host", "Hyper", "Instance", "KeyEventish",
292 "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "Options",
294 "Folded", "Frames", "Grads", "Grid", "Hit", "Host", "Hull", "Hyper", "Instance", "KeyEventish",
295 "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh", "MouseEventish", "MoveTuning", "Options",
293296 "Overlay", "Packed", "PageOptions", "Paint", "Painter", "Pin", "Policy", "QUANTUM",
294297 "Renderer", "RendererAssets", "Rollout", "Scene", "SceneCapacity", "Shader", "Shape",
295298 "SoundDevice", "Stick", "Surface", "Tone", "TouchEventish", "Touchish", "UpdateStats", "Vec3",
296 "add", "addBox", "angleDelta", "angleVectors", "applyGrads", "attachButtons", "attenuate",
299 "Wish", "accelerate", "add", "addBox", "airAccelerate", "angleDelta", "angleVectors",
300 "applyGrads", "attachButtons", "attenuate",
297301 "backward", "builder", "cellAt", "clamp", "clearGrads", "colOf", "compare",
298302 "computeAdvantages", "copyPolicy", "createAdam", "createControls", "createLoop",
299303 "createPolicy", "createRenderer", "createRollout", "createScene", "createSoundDevice",
300 "cross", "deg", "dist", "dist2d", "dot", "fakePage", "fbm", "findPath", "finish", "fold",
301 "forward", "greedy", "hash", "headOffset", "inside", "length", "length2d", "lerp",
302 "logitCount", "mat4", "mergeSolid", "modelMatrix", "mul", "multiply", "newActivations",
304 "cross", "deg", "dist", "dist2d", "dot", "eyeHeight", "fakePage", "fbm", "findPath", "finish",
305 "fold", "forward", "greedy", "groundUnder", "hash", "headOffset", "hullHeight", "inside",
306 "length", "length2d", "lerp",
307 "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations",
303308 "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter",
304309 "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox",
305 "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "sub",
310 "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "stuck", "sub",
306311 "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf",
307312 "zeroGrads", "zeroPolicy",
308313 ];
src/engine/index.ts +9
@@ -22,6 +22,15 @@ export type { Mat4, Vec3 } from "./math.ts";
2222 export { rayBox, segmentLength, traceWorld, visible } from "./trace.ts";
2323 export type { Box, Hit } from "./trace.ts";
2424
25 /**
26 * The mover: friction, acceleration, the air cap, walls, steps and the crouch,
27 * in the Quake lineage. It reads no tuned number of its own — a `MoveTuning` and
28 * a `Hull` arrive as arguments on every call — so the feel of a game is the pair
29 * it hands in and never a constant in here.
30 */
31 export { accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck } from "./physics.ts";
32 export type { Body, Hull, MoveTuning, Wish } from "./physics.ts";
33
2534 /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*. */
2635 export { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf } from "./grid.ts";
2736 export type { Grid } from "./grid.ts";
src/client/physics.ts → src/engine/physics.ts renamed +13 −33
@@ -1,21 +1,22 @@
11 /**
2 * Movement, in the Quake/Source lineage CS never left: ground friction, a
3 * capped ground acceleration, and an air acceleration that only caps the part
4 * of your wish speed along the new direction — which is why turning while you
5 * accelerate in the air makes you faster, and why strafe-jumping works here.
2 * Movement, in the Quake lineage: ground friction, a capped ground
3 * acceleration, and an air acceleration that only caps the part of your wish
4 * speed along the new direction — which is why turning while you accelerate in
5 * the air makes you faster, and why strafe-jumping works here.
66 *
7 * Units are Source units (1 unit ≈ 1.9 cm): 250 u/s running, 800 u/s² gravity,
8 * a 32×32×72 hull, an 18-unit step.
7 * The units are the caller's, throughout. A body, a hull and a tuning in metres
8 * per second and a world of boxes in metres solve the same way as one in the
9 * inch-ish units the lineage is usually written in; nothing below reads a scale.
910 *
10 * The solver holds none of those numbers itself. Every tuned value reaches a
11 * The solver holds no tuned number of its own. Every one of them reaches a
1112 * function as a required argument, in one boring order — subject, situation,
1213 * tuning, hull — and no parameter has a default, so nothing can substitute a
13 * tuning quietly. `MOVE` and `HULL` below are Counter-Strike's answers, and a
14 * caller that wants a different game hands in a different pair.
14 * tuning quietly. A game brings a `MoveTuning` and a `Hull` and gets its own
15 * feel out of the same arithmetic; `src/client/movement.ts` is one such pair.
1516 */
1617
17 import type { Box } from "./level.ts";
18 import { clamp, dot, normalize, type Vec3 } from "../engine/index.ts";
18 import type { Box } from "./trace.ts";
19 import { clamp, dot, normalize, type Vec3 } from "./math.ts";
1920
2021 /** The box a body occupies, and where it looks from, standing and ducked. */
2122 export type Hull = {
@@ -43,23 +44,6 @@ export type MoveTuning = {
4344 duckRate: number;
4445 };
4546
46 export const HULL: Hull = { radius: 16, stand: 72, duck: 54, eyeStand: 64, eyeDuck: 46 };
47
48 export const MOVE: MoveTuning = {
49 gravity: 800,
50 maxSpeed: 250,
51 duckSpeed: 85,
52 walkSpeed: 130,
53 accelerate: 5.6,
54 airAccelerate: 12,
55 airSpeedCap: 30,
56 friction: 5.2,
57 stopSpeed: 80,
58 jumpVelocity: 301.993,
59 stepSize: 18,
60 duckRate: 5,
61 };
62
6347 /**
6448 * How close under the feet a floor still counts as touched, in units. This is
6549 * the solver's own tolerance — the slack a discrete step needs so a body that
@@ -216,7 +200,7 @@ export function move(
216200 const blockedX = slideAxis(body.pos, 0, dx, height, boxes, hull);
217201 const blockedY = slideAxis(body.pos, 1, dy, height, boxes, hull);
218202
219 // A step is a second try from 18 units higher, dropped back down after.
203 // A step is a second try from `stepSize` higher, dropped back down after.
220204 if ((blockedX || blockedY) && body.onGround) {
221205 const stepped: Vec3 = [before[0], before[1], before[2] + tuning.stepSize];
222206 if (!stuck(stepped, height, boxes, hull)) {
@@ -247,7 +231,3 @@ export function move(
247231 body.onGround = false;
248232 }
249233 }
250
251 /** Fall damage, CS-style: nothing under ~580 u/s, then it bites. */
252 export const fallDamage = (impactSpeed: number): number =>
253 impactSpeed < 580 ? 0 : Math.round((impactSpeed - 580) * 0.0888 * 100) / 100;
src/games.ts +14
@@ -135,6 +135,20 @@ export type Game = {
135135 * it is absent is a test rather than an inference: `src/engine/digest.test.ts`
136136 * reads every committed bundle for the sentence that module carries, for the
137137 * complaint it writes and for `CryptoHasher`, and finds none of them.
138 * #123 moved the mover behind the door — `src/engine/physics.ts`, the Quake-lineage
139 * solver the shooter walks on, with Counter-Strike's numbers left in
140 * `src/client/movement.ts` where a reader can find them — and no bundle gained
141 * anything worth the name: app 72,128 → 72,132 raw and 33,546 → 33,552 gzipped,
142 * range 28,978 → 28,980 and 12,319 → 12,323, example 8,754 raw unchanged to the
143 * byte and 4,225 → 4,228 gzipped. Four bytes on the shooter is a rename and an
144 * import line; the two other games gained the same handful because the minifier
145 * hands out different short names over a module graph that changed shape, which
146 * is what happened for #106, #107 and #110. The range and the example ship none
147 * of the solver and that is a test rather than an inference:
148 * `src/range/bundle.test.ts` reads the committed file for `airSpeedCap`,
149 * `duckRate`, `jumpVelocity` and `stopSpeed` and finds none of them, while
150 * `src/client/bundle.test.ts` finds all four and the jump velocity itself.
151 * Measured 2026-09-20 after #123, on the bundles this branch commits.
138152 * The range's numbers here are #121's moving plates and #125's painter at once:
139153 * both landed a range bundle and neither swept the other's, so static/ held two
140154 * and every reader of it failed until a plain `bun run build` resolved them.
src/independence.test.ts +1 −1
@@ -154,7 +154,7 @@ test("the engine's door, the shell, a sibling and the app are what a game may re
154154 expect(importsAnotherGame("src/range/main.ts", "../engine/index.ts")).toBeNull();
155155 expect(importsAnotherGame("src/client/main.ts", "../pages/shell.ts")).toBeNull();
156156 expect(importsAnotherGame("src/range/main.ts", "../pages/shell.ts")).toBeNull();
157 expect(importsAnotherGame("src/client/world.ts", "./physics.ts")).toBeNull();
157 expect(importsAnotherGame("src/client/world.ts", "./movement.ts")).toBeNull();
158158 expect(importsAnotherGame("src/range/main.ts", "./binding.ts")).toBeNull();
159159 expect(importsAnotherGame("src/client/main.test.ts", "../result.ts")).toBeNull();
160160 expect(importsAnotherGame("src/client/world.ts", "gl-matrix")).toBeNull();
src/pages/range.ts +3 −2
@@ -101,8 +101,9 @@ export function rangePage(options: PageOptions): string {
101101 the shot leaves a tracer for eighty milliseconds, and the atlas they are painted from is
102102 three 64-pixel layers of this game's own — not a line of the shooter's map, models or
103103 materials. What a frame looks like is plain data in <code>src/range/scene.ts</code>, read
104 by a test with no browser in the room. Still missing is the walking: the eye stands at the
105 firing line until the engine's physics lands. If your browser has no WebGL2, the flat
104 by a test with no browser in the room. Still missing is the walking: the mover is behind
105 the engine's door now, and the eye stands at the firing line until this game hands it a
106 tuning and a hull of its own. If your browser has no WebGL2, the flat
106107 horizon is still there and the panel says so.</p>
107108 <p>You can hear it too, and that is the last part of the engine this page was not using: its
108109 sound device, with five recipes of the range's own in <code>src/range/audio.ts</code> — the
src/range/bundle.test.ts +12
@@ -126,6 +126,18 @@ test("the range bundles none of the engine's policy network", () => {
126126 for (const name of ["Math.tanh", "atob", "btoa"]) expect(code).not.toContain(name);
127127 });
128128
129 test("the range bundles none of the engine's mover either", () => {
130 // The mover went behind the door in #123 and the range still walks nobody, so
131 // the door widening cost this bundle nothing. Read as property names, which
132 // the minifier cannot rename because a game's tuning literal writes them: a
133 // bundle that carried the solver would carry the fields it reads out of a
134 // `MoveTuning`. The shooter's bundle has all four — `src/client/bundle.test.ts`
135 // is the other end of this claim.
136 const code = readFileSync(bundlePath("range"), "utf8");
137 for (const field of ["airSpeedCap", "duckRate", "jumpVelocity", "stopSpeed"])
138 expect(code).not.toContain(field);
139 });
140
129141 test("the stub page leaves nothing of itself behind", () => {
130142 // Every boot above undid its install, so no other test inherits a document.
131143 expect("document" in globalThis).toBe(HAD_DOCUMENT);
src/range/main.ts +1 −1
@@ -181,7 +181,7 @@ export function boot(): void {
181181 ticks += steps;
182182
183183 // What was asked for this frame. The movement half of it is still only
184 // drawn — there is nothing to move until the engine's physics lands — and
184 // drawn — the engine's mover is there, and nothing here hands it a tuning yet — and
185185 // the trigger and Enter half of it is the game.
186186 const intent = readIntent(controls.held, controls.stick);
187187
static/app.1d36f87326.js added +58

Diff not shown — a line 57,194 characters wide (limit 2,000). +58

View file
static/app.fc9923a472.js deleted −58

Diff not shown — a line 59,545 characters wide (limit 2,000). −58

View file
static/example.4055b264fb.js added +1

Diff not shown — a line 8,748 characters wide (limit 2,000). +1

View file
static/example.801f9085bb.js deleted −1

Diff not shown — a line 8,748 characters wide (limit 2,000). −1

View file
static/range.b3648bb3ad.js added +58

Diff not shown — a line 17,032 characters wide (limit 2,000). +58

View file
static/range.e5be537f51.js deleted −58

Diff not shown — a line 17,026 characters wide (limit 2,000). −58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs