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

forked from trevato/cs

The registry stops being a changelog: src/games.ts is data and a rule, and the build prints the sizes #264

Merged opened this

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

Merged

torque/cs-engine:torque/182-the-registry-stops-being-a-changelog-src → main 2 commits fork

  • Merged. torque/182-the-registry-stops-being-a-changelog-src was merged into main as bf1efee.

Review

Sign in to review this change.
4 files changed +204 −449
README.md +26 −18
@@ -1139,26 +1139,34 @@ never checked itself.
11391139 stops the server before it listens**, naming the directory it could not write,
11401140 and so does a game that resolved to no bundle or to two: a failed deploy whose
11411141 first line says so, rather than a perfect page around a `<script>` that 404s.
1142 - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints
1143 these next to each file name:
1142 - **Every bundle has a ceiling, and the build prints what it built.** The
1143 numbers are not written down here, because a number written down goes stale
1144 the next time anyone lands anything: `bun run scripts/build.ts` prints a row
1145 per file — measured, ceiling and the headroom left, raw and gzipped — and a
1146 pull that quotes a size copies it from a run. A run today looks like this:
11441147
1145 | bundle | raw | ceiling | gzipped | ceiling |
1146 | --------- | ------ | ------- | ------- | ------- |
1147 | `app` | 92,018 | 96,000 | 41,774 | 43,500 |
1148 | `range` | 34,507 | 37,000 | 14,449 | 15,500 |
1149 | `example` | 9,735 | 10,800 | 4,619 | 5,100 |
1148 ```
1149 file raw ceiling left gzip-9 ceiling left
1150 ------------------------------ -------- -------- -------- -------- -------- --------
1151 static/app.75963f956b.js 99791 103000 3209 39385 41000 1615
1152 static/range.3dad1a6765.js 34810 37000 2190 14568 15500 932
1153 static/example.a07275bd88.js 21115 23500 2385 9190 10200 1010
1154 static/weights.f18906194d.json 11120 12300 1180 8251 9100 849
1155 ```
11501156
1151 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
1152 and only at level 9, so the number does not move between a laptop and the
1153 forge. `scripts/build.test.ts` builds the sources and weighs both what the
1154 bundler returned and what landed on disk against both ceilings — and the same
1155 test proves two builds of one tree produce the same bytes under the same
1156 names, which is what makes a year-long cache safe. The failure names the
1157 bundle, what it measured, what it
1158 may not pass and the overshoot in bytes and percent. **Raising a ceiling is
1159 allowed**: edit the number on that game's entry in `src/games.ts`, where the
1160 log of what moved each one lives, and say in the pull what was added and why
1161 it is worth the bytes. What is not allowed is growth nobody noticed.
1157 A ceiling is the size measured when it was set plus about five to ten per
1158 cent, rounded up; they live on the entries in `src/games.ts`, next to the
1159 rule for moving one. Gzip is measured at level 9 and only at level 9, so the
1160 number does not move between a laptop and the forge.
1161 `scripts/build.test.ts` builds the sources and weighs both what the bundler
1162 returned and what landed on disk against both ceilings — and the same test
1163 proves two builds of one tree produce the same bytes under the same names,
1164 which is what makes a year-long cache safe. The failure names the bundle,
1165 what it measured, what it may not pass and the overshoot in bytes and
1166 percent; the table says the same thing more quietly, as a headroom that has
1167 gone negative. **Raising a ceiling is allowed**: edit the number on that
1168 game's entry, and say in the pull what was added, what it cost and why it is
1169 worth the bytes. What is not allowed is growth nobody noticed.
11621170 - No dependencies. Nothing is installed to run this.
11631171
11641172 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
scripts/build.test.ts +88 −6
@@ -11,11 +11,15 @@ import {
1111 isNamed,
1212 GZIP_LEVEL,
1313 isBundleOf,
14 line,
14 NOTE,
1515 overshoot,
16 row,
1617 sizes,
18 table,
1719 weigh,
1820 write,
21 type Built,
22 type Ceiling,
1923 type Entry,
2024 } from "./build.ts";
2125 import { GAMES } from "../src/games.ts";
@@ -244,11 +248,89 @@ test("the asset every game declares is under its ceiling, and is what its source
244248 }
245249 });
246250
247 test("the build prints each bundle's raw and gzipped size next to its name", () => {
251 /** A built file of known contents, for the table below: sizes nobody has to guess at. */
252 const builtFile = (name: string, code: string, ceiling: Ceiling): Built => ({
253 name,
254 ext: "js",
255 file: `${name}.6864d577a4.js`,
256 code,
257 ceiling,
258 });
259
260 test("a row is one file weighed once: what it measured, what it may not pass, what is left", () => {
248261 const code = `console.log("hi");\n`;
249262 const { raw, gzip } = sizes(code);
250 const printed = line({ name: "app", ext: "js", file: "app.6864d577a4.js", code, ceiling: { raw: 400, gzip: 300 } });
251 expect(printed).toContain("static/app.6864d577a4.js");
252 expect(printed).toContain(`${raw} B raw / 400`);
253 expect(printed).toContain(`${gzip} B gzip-${GZIP_LEVEL} / 300`);
263 const measured = row(builtFile("app", code, { raw: 400, gzip: 300 }));
264 expect(measured.measured).toEqual({ raw, gzip });
265 expect(measured.headroom).toEqual({ raw: 400 - raw, gzip: 300 - gzip });
266 // Over the ceiling the headroom is negative, in the same column a reader is
267 // already scanning — not a row that quietly looks like the others.
268 const over = row(builtFile("app", code, { raw: 4, gzip: 3 }));
269 expect(over.headroom).toEqual({ raw: 4 - raw, gzip: 3 - gzip });
270 expect(over.headroom.raw).toBeLessThan(0);
271 });
272
273 test("the build prints a table of what it built: the sizes and the headroom under each ceiling", () => {
274 const code = `console.log("hi");\n`;
275 const long = `console.log(${JSON.stringify("x".repeat(500))});\n`;
276 const { raw, gzip } = sizes(code);
277 const big = sizes(long);
278 const printed = table([
279 builtFile("app", code, { raw: 400, gzip: 300 }),
280 builtFile("range", long, { raw: 900, gzip: 400 }),
281 ]);
282
283 const [head, rule, app, range] = printed as [string, string, string, string];
284 expect(head.split(/\s+/)).toEqual(["file", "raw", "ceiling", "left", `gzip-${GZIP_LEVEL}`, "ceiling", "left"]);
285 expect(rule).toMatch(/^-+( +-+)+$/);
286 // Each row is its file and the six numbers, in the order the heading names
287 // them: measured, ceiling and what is left, on both rulers.
288 expect(app.split(/\s+/)).toEqual([
289 "static/app.6864d577a4.js",
290 String(raw),
291 "400",
292 String(400 - raw),
293 String(gzip),
294 "300",
295 String(300 - gzip),
296 ]);
297 expect(range.split(/\s+/)).toEqual([
298 "static/range.6864d577a4.js",
299 String(big.raw),
300 "900",
301 String(900 - big.raw),
302 String(big.gzip),
303 "400",
304 String(400 - big.gzip),
305 ]);
306 // The columns line up, so two rows can be compared by eye.
307 expect(rule.length).toBe(head.length);
308 expect(app.length).toBe(rule.length);
309 expect(range.length).toBe(rule.length);
310 // And the directory it wrote into is the one named in the rows.
311 expect(table([builtFile("app", code, { raw: 400, gzip: 300 })], "/tmp/out")[2]).toContain(
312 "/tmp/out/app.6864d577a4.js",
313 );
314 // The note under it carries the units and the ruler, so a pasted table means
315 // something away from this file.
316 expect(NOTE).toContain("Bytes");
317 expect(NOTE).toContain(`gzip-${GZIP_LEVEL}`);
318 expect(NOTE).toContain("src/games.ts");
319 });
320
321 test("a file over its ceiling prints a negative headroom, and is still the failure it is", async () => {
322 const { dir, entries } = fixture();
323 const tight = entries.map((entry) => ({ ...entry, ceiling: { raw: 10, gzip: 10 } }));
324 const built = await bundle(tight);
325 const printed = table(built);
326 expect(printed[0]).toContain("left");
327 const app = (printed[2] as string).split(/\s+/);
328 expect(Number(app[3])).toBeLessThan(0);
329 expect(Number(app[6])).toBeLessThan(0);
330 // Printing it is not forgiving it: the same numbers are what the ceiling
331 // tests above fail on, with the sentence naming the overshoot.
332 for (const { name, code, ceiling } of built) {
333 expect(overshoot(name, sizes(code), ceiling)).toContain("is too big");
334 }
335 rmSync(dir, { recursive: true, force: true });
254336 });
scripts/build.ts +66 −7
@@ -10,6 +10,10 @@
1010 * read, weigh or serve another way. Each entry carries the size it may not pass,
1111 * and `build.test.ts` builds and weighs it.
1212 *
13 * Run as a program it prints a table of what it built — every file, raw and
14 * gzipped, against its ceiling and the headroom left under it — so the sizes a
15 * pull states are copied from a run rather than remembered.
16 *
1317 * A bundle and an asset are the same thing here — bytes filed under a name and
1418 * their own content hash, swept when they change and held to a ceiling — and
1519 * they differ only in where the bytes come from: a bundler, or a file. That is
@@ -41,7 +45,7 @@ export type Entry = { name: string; entry: string; ceiling: Ceiling };
4145 /**
4246 * What is built, projected from the registry in `src/games.ts`: a game is
4347 * bundled because it is a game, not because it is also written down here. The
44 * ceilings and the log of what moved them live beside the entries there.
48 * ceilings live beside the entries there, with the rule for moving one.
4549 */
4650 export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling }));
4751
@@ -221,10 +225,58 @@ export async function write(
221225 return built;
222226 }
223227
224 /** `static/app.3f2a9c1b04.js 68457 B raw / 75400 · 31661 B gzip-9 / 34900`. */
225 export function line({ file, code, ceiling }: Built, where = "static"): string {
226 const { raw, gzip } = sizes(code);
227 return `${where}/${file} ${raw} B raw / ${ceiling.raw} · ${gzip} B gzip-${GZIP_LEVEL} / ${ceiling.gzip}`;
228 /**
229 * One row of the build's table: what was built, what it weighs on both rulers,
230 * what it may not pass and what is left under that — negative when it passed it,
231 * because a row that is over says so in the same column the others are read in.
232 */
233 export type Row = { name: string; file: string; measured: Ceiling; ceiling: Ceiling; headroom: Ceiling };
234
235 /** What one built file measured, weighed once and reported as a row. */
236 export function row({ name, file, code, ceiling }: Built): Row {
237 const measured = sizes(code);
238 return {
239 name,
240 file,
241 measured,
242 ceiling,
243 headroom: { raw: ceiling.raw - measured.raw, gzip: ceiling.gzip - measured.gzip },
244 };
245 }
246
247 /** What the table is a table of, printed under it so a pasted table carries its units. */
248 export const NOTE = [
249 "",
250 `Bytes. Every row is one file in static/: a game's bundle, or an asset it`,
251 `downloads beside one. "raw" is the file on disk and "gzip-${GZIP_LEVEL}" is it compressed`,
252 `at the one level the ceilings are measured at; "left" is the headroom under`,
253 `that ceiling, and a negative one is a bundle over it, which fails`,
254 `scripts/build.test.ts. The ceilings are in src/games.ts, with the rule for`,
255 `moving one. Run: bun run scripts/build.ts`,
256 ].join("\n");
257
258 /**
259 * The whole table, as lines a person reads and a pull quotes: one built file a
260 * row, in the order they were built. It is the reason no pull has to remember a
261 * size — the numbers a reviewer is given are copied from a run of this.
262 */
263 export function table(built: readonly Built[], where = "static"): string[] {
264 const rows = built.map(row);
265 const width = Math.max(4, ...rows.map((r) => `${where}/${r.file}`.length));
266 const cell = (text: string, size = 8): string => text.padStart(size);
267 const head =
268 `${"file".padEnd(width)} ${cell("raw")} ${cell("ceiling")} ${cell("left")} ` +
269 `${cell(`gzip-${GZIP_LEVEL}`)} ${cell("ceiling")} ${cell("left")}`;
270 return [
271 head,
272 `${"-".repeat(width)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)} ${"-".repeat(8)}`,
273 ...rows.map(
274 (r) =>
275 `${`${where}/${r.file}`.padEnd(width)} ${cell(String(r.measured.raw))} ${cell(String(r.ceiling.raw))} ` +
276 `${cell(String(r.headroom.raw))} ${cell(String(r.measured.gzip))} ${cell(String(r.ceiling.gzip))} ` +
277 `${cell(String(r.headroom.gzip))}`,
278 ),
279 ];
228280 }
229281
230282 /**
@@ -235,10 +287,17 @@ export function line({ file, code, ceiling }: Built, where = "static"): string {
235287 * process for the bundles because the engine may import nothing outside itself.
236288 *
237289 * The sizes are printed, not enforced: the ceiling is a test, so a build that
238 * overshoots still writes the file a reviewer can then weigh.
290 * overshoots still writes the file a reviewer can then weigh — and says so
291 * under the table, in the sentence the test would fail with.
239292 */
240293 if (import.meta.main) {
241294 const asked = process.argv[2];
242295 const dir = asked === undefined ? STATIC_DIR : resolve(asked);
243 for (const built of await write(ENTRIES, dir)) console.log(line(built, asked === undefined ? "static" : dir));
296 const built = await write(ENTRIES, dir);
297 for (const printed of table(built, asked === undefined ? "static" : dir)) console.log(printed);
298 console.log(NOTE);
299 for (const { name, measured, ceiling } of built.map(row)) {
300 const over = overshoot(name, measured, ceiling);
301 if (over !== null) console.log(over);
302 }
244303 }
src/games.ts +24 −418
@@ -69,425 +69,27 @@ export type Game = {
6969 /**
7070 * In the order the header lists them, which is also the order they are built.
7171 *
72 * #151 gave a generated map its own look — `src/client/looks.ts`, eight named
73 * sets of the nine materials already in the atlas, one chosen from the seed —
74 * and the app grew 441 raw / 211 gzipped bytes for it: the table, the stirring
75 * that indexes it, the look a level now carries, and the one word the menu's
76 * preview sentence ends with. No repaint, no tenth layer, nothing new over the
77 * wire: the pixels were uploaded at boot either way, and what changed is which
78 * layer a box's vertices name. It fits today's
79 * 98,000 / 39,000 with 2,530 raw and 1,258 gzipped bytes to spare, so no
80 * ceiling moved. Measured 2026-09-21 against a *rebuild* of main rather than
81 * against main's committed bundle: #235 left two app bundles in `static/` and
82 * the newer of them was already stale, so the number quoted below (92,978) is
83 * not what main's source builds — that is 95,029 raw / 37,531 gzipped, and
84 * this branch rebuilds it and commits the one file, which is also what puts
85 * `bundle.test.ts` back to passing.
72 * **The rule for the ceilings below.** A ceiling is two numbers — bytes on disk
73 * and bytes gzipped at level 9, the one ruler `scripts/build.ts` measures with —
74 * and every bundle and every asset has one. Each is the size measured the day it
75 * was set plus about five to ten per cent, rounded up: enough headroom that an
76 * honest feature lands without a ceiling edit, tight enough that a stray import
77 * of something large trips `scripts/build.test.ts`, which weighs what the
78 * bundler returned and what landed on disk against both halves.
8679 *
87 * #228 gave the bots a round to play rather than five separate ones — a site, a
88 * shape, a tempo and a save per side, and a job per bot cut from it — and the
89 * app grew 3,804 raw bytes for it (92,978 raw / 36,716 gzipped now): `src/plan/plan.ts` (the plan,
90 * the jobs and the parser, all of which ship), the situation the page sends, and
91 * the wiring in the entry that asks for one a round. It fits today's 98,000 /
92 * 39,000 with 5,022 raw and 2,284 gzipped bytes to spare, so no ceiling moved.
93 * The planner itself — `src/plan/typesafe.ts` and `@typesafe-ai/sdk` — is behind
94 * the door like the trainer and the digest: it holds the key, it runs on the
95 * server, and no bundle gained a byte of it. The range and the example are
96 * unchanged to the byte: they have no bots to plan for. Measured 2026-09-21.
97 * The ceilings are today's sizes plus about 10%, rounded up to a round number:
98 * enough headroom that an honest feature lands without a ceiling edit, tight
99 * enough that a stray import of something large trips the test. Measured
100 * 2026-09-17: app 69,711 raw / 32,139 gzipped, range 12,548 raw / 5,726 gzipped.
101 * The range grew 973 bytes for #49, which moved its panel, its lines and its
102 * crosshair onto the engine's overlay — a tool set is bundled whole, so a game
103 * that uses three of its tools still ships all of them. It fits, with about 250
104 * raw and 70 gzipped bytes to spare: the next thing the range gains will want
105 * this ceiling raised, and #18 takes the same bytes back out of the shooter.
106 * The app grew 1,254 bytes for #47, which moved every decision about a frame
107 * into `src/client/scene.ts`; it fits under today's ceiling, so no ceiling moved
108 * with it. It grew another 830 raw / 701 gzipped for #48, which moved the
109 * renderer behind the door: the game now builds all eight of its meshes and
110 * hands them in at construction rather than uploading each on the frame that
111 * first asks for it. Measured after #48: app 70,558 raw / 32,850 gzipped,
112 * range 12,548 raw / 5,729 gzipped — the range is unchanged in size, which is
113 * the tree-shaking working: it imports the door and none of the WebGL2 behind
114 * it. The range's ceiling was raised for #41, which gave it a game: the targets,
115 * the clock and the score, the shot traced through the engine's `rayBox`, and
116 * the blips the horizon draws them as.
117 * The range's ceiling was raised again for #52, which gave it sound: the
118 * engine's `sound` module and `src/range/audio.ts`'s five recipes together are
119 * 2,444 raw and 956 gzipped bytes. Measured 2026-09-18: range 26,658 raw /
120 * 11,456 gzipped, which fitted the old 26,700 / 11,600 by 42 bytes — not a
121 * ceiling anybody is holding — so it is today's size plus about 10% again.
122 * Raising a ceiling is allowed, and it is a line in the pull that says what was
123 * added and why.
124 * #78 moved the frame's pool behind the door — `createScene`, `resetScene`,
125 * `nextInstance` and `pushLine`, which both games now fill instead of each
126 * keeping its own arithmetic — and both bundles grew by about 580 raw bytes for
127 * it, because the container ships whole to a game that uses any of it. Measured
128 * 2026-09-18 after #78: app 71,186 raw / 33,125 gzipped, range 27,234 raw /
129 * 11,655 gzipped. Both fit today's ceilings, so neither moved.
130 * #95 made the map a value — `src/client/level.ts` says what a level is and
131 * `DE_FORGE` is one, handed to `createWorld` instead of imported — and the app
132 * grew 460 raw / 143 gzipped bytes for it: the level's own functions, and a
133 * level argument on the calls that used to reach for a singleton. Measured
134 * 2026-09-18 after #95 merged with #103: app 71,646 raw / 33,272 gzipped,
135 * range unchanged at 27,234 raw / 11,656 gzipped — #103's headless page is a
136 * test helper and ships in neither bundle, so the app is the same size to the
137 * byte and differs only in which short names the minifier handed out. Both
138 * fit, so neither ceiling moved.
139 * #106 moved the policy network behind the door — `src/engine/net.ts`, which the
140 * shooter's bots run every tick and the trainer trains — and neither bundle
141 * gained a line of it: the app is 71,646 raw again, to the byte, and the range
142 * grew 34 raw / 18 gzipped bytes, which is the minifier handing out different
143 * short names over a module graph that changed shape. The range ships none of
144 * the net and its ceiling is the proof: `Math.tanh`, `atob` and `btoa` appear in
145 * the app's bundle and in neither line of the range's. Measured 2026-09-18 after
146 * #106: app 71,646 raw / 33,273 gzipped, range 27,268 raw / 11,674 gzipped.
147 * Both fit, so neither ceiling moved.
148 * #88 gave the range a second drill — plates that slide across the lane, chosen
149 * before a run — and the range grew 1,545 raw / 572 gzipped bytes for it: a
150 * velocity on every plate, a second entry in the drill table, the retirement of
151 * a plate that has left the lane, and the drill's name and its per-drill best on
152 * the panel. Measured 2026-09-20 after #88: range 28,813 raw / 12,246 gzipped,
153 * which fits today's 29,400 / 12,700 with 587 raw and 454 gzipped bytes to
154 * spare, so the ceiling did not move — but that is the thinnest headroom on this
155 * page, and the next thing the range gains will want it raised. Neither other
156 * bundle moved a byte: nothing outside `src/range/` changed.
157 * #80 added a third entry, `example`, and its ceiling is the first number in
158 * this file nobody had to argue about: the guide's game with a page around it
159 * builds to 8,754 raw / 4,229 gzipped, so its ceiling is 9,700 / 4,700, today's
160 * size plus about 10% like the other two. It is an eighth of the shooter
161 * because it is 2D: no renderer, no atlas, no sound and no net ship in it, and
162 * `src/example/bundle.test.ts` reads the file to say so. Neither other bundle
163 * moved a byte for it.
164 * #99 made the atlas paintable a slice at a time — `painter` in
165 * `src/engine/texture.ts`, which `paintAtlas` is now one call of — and the
166 * shooter paints a layer a frame through it instead of freezing the page for
167 * 170 ms inside `boot`. The app grew 402 raw / 184 gzipped bytes for the
168 * painter's state and the boot that steps it, and the range grew 165 raw / 71
169 * gzipped for the painter under the one call it still makes. Measured
170 * 2026-09-20 after #99: app 72,048 raw / 33,457 gzipped, range 27,433 raw /
171 * 11,745 gzipped, example 8,754 raw / 4,228 gzipped — the same size to the byte,
172 * differing only in which short names the minifier handed out, because it
173 * paints no texture and ships none of this. Both fit today's ceilings, so
174 * neither moved.
175 * #107 moved PPO behind the door too — `src/engine/learn.ts`, which no browser
176 * will ever load — and not one of the three bundles gained a byte: app 72,048
177 * raw, range 28,978 raw, example 8,754 raw, each the same number as before it
178 * moved. The gzipped sizes moved by a byte or two (app 33,457 → 33,482, example
179 * 4,228 → 4,227) because the minifier handed out different short names over a
180 * module graph that changed shape, which is the same thing that happened for
181 * #106. That the trainer is absent is a test rather than an inference:
182 * `src/engine/learn.test.ts` reads every committed bundle for `maxGradNorm`,
183 * `valueCoef`, `lastValues`, `clipFraction` and `policyLoss` and finds none of
184 * them. Measured 2026-09-20 after #107, on the bundles this branch commits.
185 * #110 moved the determinism harness behind the door — `src/engine/digest.ts`,
186 * the quantiser, the fold and the complaint both games pin themselves with —
187 * and not one of the three bundles gained a byte: app 72,128 raw, range 28,978
188 * raw, example 8,754 raw, each the same number as before it moved. The gzipped
189 * sizes moved a little (app 33,410 → 33,546, range 12,323 → 12,319, example
190 * 4,219 → 4,225) because the minifier handed out different short names over a
191 * module graph that changed shape, which is what happened for #106 and #107
192 * too. A digest is a tool for a test or a bench and never for a frame, and that
193 * it is absent is a test rather than an inference: `src/engine/digest.test.ts`
194 * reads every committed bundle for the sentence that module carries, for the
195 * complaint it writes and for `CryptoHasher`, and finds none of them.
196 * #123 moved the mover behind the door — `src/engine/physics.ts`, the Quake-lineage
197 * solver the shooter walks on, with Counter-Strike's numbers left in
198 * `src/client/movement.ts` where a reader can find them — and no bundle gained
199 * anything worth the name: app 72,128 → 72,132 raw and 33,546 → 33,552 gzipped,
200 * range 28,978 → 28,980 and 12,319 → 12,323, example 8,754 raw unchanged to the
201 * byte and 4,225 → 4,228 gzipped. Four bytes on the shooter is a rename and an
202 * import line; the two other games gained the same handful because the minifier
203 * hands out different short names over a module graph that changed shape, which
204 * is what happened for #106, #107 and #110. The range and the example ship none
205 * of the solver and that is a test rather than an inference:
206 * `src/range/bundle.test.ts` reads the committed file for `airSpeedCap`,
207 * `duckRate`, `jumpVelocity` and `stopSpeed` and finds none of them, while
208 * `src/client/bundle.test.ts` finds all four and the jump velocity itself.
209 * Measured 2026-09-20 after #123, on the bundles this branch commits.
210 * #132 put the stage on the whole screen — `src/engine/fullscreen.ts` behind the
211 * door, and in each of the three entries the button over the stage, the key
212 * that toggles it and the label that follows the state — and all three bundles
213 * grew, because unlike the modules above this one is code a browser runs: app
214 * 72,132 → 73,130 raw and 33,552 → 33,948 gzipped, range 28,980 → 29,975 and
215 * 12,323 → 12,704, example 8,754 → 9,735 and 4,228 → 4,620. About a kilobyte
216 * each, and it is the same kilobyte three times: roughly 300 bytes of the
217 * engine's module and the rest the wiring — `aria-pressed`, `aria-label`, the
218 * title and the two glyphs are strings a minifier cannot shorten. The app fits
219 * today's ceiling. The range's and the example's did not, and both are raised
220 * here to today's size plus about 10%, which is what every other number on this
221 * page is: range 29,400/12,700 → 33,000/14,000 (it had 42 bytes of headroom
222 * after #52 and 587 after #88, so this was the raise those two lines said was
223 * coming), example 9,700/4,700 → 10,800/5,100.
224 * #138 gave the shooter maps nobody wrote — `src/client/generate.ts`, a seed in
225 * and a `Level` out — and put them on the menu beside de_forge, so this is the
226 * first entry on this page that is a feature rather than a move: the app grew
227 * 4,604 raw / 2,035 gzipped bytes for the carving, the crates, the gate that
228 * reads a finished map for flaws, and the wiring that swaps the level's mesh
229 * when a match starts on another layout. It did not fit 75,400/34,900, and the
230 * ceiling is raised here to 82,000/38,000 — today's 77,966 raw / 36,059 gzipped
231 * plus about five per cent, which is room for an honest feature and not room
232 * for a stray import of something large. The range grew 148 raw / 47 gzipped
233 * for the engine's side of that swap (`Renderer.replace`, which it never calls)
234 * and the example is unchanged to the byte: it ships no renderer at all.
235 * Measured 2026-09-20 after #138, on the bundles this branch commits.
236 * #133 gave the shooter a spectator — `src/client/spectate.ts`, the choice of
237 * who you watch once you are dead and the eased camera that follows them — and
238 * the app grew 1,488 raw / 579 gzipped bytes for it: the module itself, the
239 * `view` the scene and the HUD are now drawn from, and the banner that names
240 * whose eyes they are. Measured 2026-09-20 after #133 on top of #132 and #138:
241 * app 79,454 raw / 36,638 gzipped, which fits the 82,000 / 38,000 #138 raised
242 * with 2,546 raw and 1,362 gzipped to spare, so the ceiling did not move.
243 * Neither other bundle changed a byte: nothing outside `src/client/` did.
244 * The range's numbers here are #121's moving plates and #125's painter at once:
245 * both landed a range bundle and neither swept the other's, so static/ held two
246 * and every reader of it failed until a plain `bun run build` resolved them.
247 * #18 moved the shooter's HUD onto the engine's `overlay`, and the line above
248 * that said it "takes the same bytes back out of the shooter" was wrong: the
249 * app grew 483 raw / 498 gzipped, to 79,937 / 37,136. A tool set is bundled
250 * whole — the same thing that cost the range 973 bytes in #49 — so the shooter
251 * now ships `columns`, `clock` and the pad arithmetic it does use next to the
252 * closures it deleted, and what it deleted was small: a `panel` of two lines, a
253 * `clock` of two and three template strings. The bytes bought one copy of the
254 * furniture instead of two, and the app fits 82,000 / 38,000 with 2,063 raw and
255 * 864 gzipped to spare, so the ceiling did not move. Neither other bundle
256 * changed a byte: nothing outside `src/client/hud.ts` did.
257 * #142 put the first grenade in the game — `src/client/grenade.ts`, a thrown
258 * body the engine's own solver moves, its mesh, its three sounds and the blast
259 * that reaches only what it can see — and the app grew 4,199 raw / 1,404
260 * gzipped bytes for it on top of the HUD move above: the module itself, the
261 * grenade in the weapon table, the pocket and the throw in `world.ts`, the
262 * body and the star of light in `scene.ts`, the recipes in `audio.ts` and the
263 * key in `binding.ts`. It did not fit 82,000/38,000 — it passed the gzip
264 * ceiling by 578 bytes — and the ceiling is raised here to 88,000/40,500,
265 * today's 84,136 raw / 38,540 gzipped plus about five per cent, which is room
266 * for an honest feature and not room for a stray import of something large.
267 * The range did not move a byte. The example is 9,735 raw again, the same
268 * number to the byte, and differs only in which short names the minifier
269 * handed out over a module graph that changed shape — it ships no grenade, no
270 * world and no weapons at all. Measured 2026-09-20 after #142 was merged with
271 * #152 and #158, on the bundles this branch commits.
272 * #63 gave the range a person: `src/range/movement.ts`'s tuning and hull, and
273 * the engine's solver stepping a body through the room every tick. It is the
274 * first thing the range has ever bundled out of `src/engine/physics.ts` — the
275 * line above about it shipping none of the solver was true until this pull and
276 * is not any more — and the mover, the wish, the eye and the stance line
277 * together cost 4,665 raw / 1,443 gzipped bytes: range 28,980 → 33,645 raw and
278 * 12,704 → 14,147 gzipped. That is over the 33,000 / 14,000 #132 set, so the
279 * ceiling is raised here to 37,000 / 15,500 — today's size plus about ten per
280 * cent, which is what every other number on this page is. 134 raw of those
281 * bytes are #142's: `restitution` is a required field on a `MoveTuning` now, so
282 * the range writes its own zero — a person does not bounce off concrete — and
283 * the solver's two new branches ship with the rest of it. Neither other bundle
284 * moved a byte for this pull: nothing outside `src/range/` changed here.
285 * Measured 2026-09-20 with #142, #152 and #158 merged in.
286 * #176 gave the shooter a radar — `src/client/minimap.ts`, the level from above
287 * in the corner of the HUD — and the app grew 1,875 raw / 935 gzipped bytes for
288 * it: the module itself, the palette the HUD and the map now share, and the
289 * engine's `mergeSolid` called a second way (it was already bundled, for the
290 * geometry, so what shipped here is the radar's own drawing and nothing else).
291 * Measured 2026-09-20 after #176: app 86,909 raw / 39,626 gzipped, which fits
292 * the 88,000 / 40,500 #142 set with 1,091 raw and 874 gzipped bytes to spare, so
293 * the ceiling did not move — but like the range's after #88 that is the thinnest
294 * headroom on this page, and the next thing the shooter gains will want it
295 * raised. Neither other bundle moved a byte: nothing outside `src/client/` did.
296 * #164 put the second grenade in the game — a flashbang through the machinery
297 * #142 built, which is one `Grenade.weapon`, one `Flash` row in the weapon
298 * table, `blindSeconds` and `flashHits`, a `blind` on every body, the white-out
299 * in the HUD, the flashed bot in `bots.ts`, a mesh, a sound and a key. The app
300 * grew 2,361 raw / 742 gzipped bytes for all of it, measured on top of #176:
301 * 86,909 → 89,270 raw and 39,626 → 40,368 gzipped. That is past the raw half of
302 * the 88,000 / 40,500 #142 set — the line above about that being the thinnest
303 * headroom on this page was right, and this is the pull that wanted it raised —
304 * so the ceiling is 92,000 / 42,000 here, today's size plus about three per
305 * cent of raw and four of gzip, which is room for an honest feature and not
306 * room for a stray import of something large. Neither other bundle moved a
307 * byte: nothing outside `src/client/` and `src/pages/game.ts` changed here, and
308 * neither of them imports either. Measured 2026-09-20, with #176 merged in.
309 * #113 split the round's rules out of `src/client/world.ts` — the money into
310 * `src/client/economy.ts` and the objective into `src/client/bomb.ts` — and the
311 * app grew 80 raw / 132 gzipped bytes for it: 90,429 → 90,509 raw and 40,801 →
312 * 40,933 gzipped. Nothing was added and nothing removed; the bytes are two more
313 * module wrappers and the short names the minifier hands out over a module graph
314 * that changed shape, the same thing that happened for #106, #107, #110 and
315 * #123. It fits the 92,000 / 42,000 #164 set with 1,491 raw and 1,067 gzipped to
316 * spare, so no ceiling moved, and the golden digests in
317 * `src/client/replay.test.ts` are unchanged to the byte, which is the real
318 * measurement here. Neither other bundle moved: nothing outside `src/client/`
319 * changed. Measured 2026-09-20 after #113.
320 * #177 retrained the policy on ninety-six maps instead of one, and the shooter
321 * grew 364 raw / 176 gzipped bytes for it, measured on top of #113: 90,509 →
322 * 90,873 raw and 40,933 → 41,109 gzipped. Almost none of that is code — the
323 * weights are the same eight kilobytes of int8 they always were, and the only
324 * new lines in the bundle are `pathTo` in `src/client/bots.ts` and five more
325 * numbers on the card. What grew is the base64: int8 that quantises to fewer
326 * runs of the same byte compresses less well, which is a retrained policy and
327 * not a feature. `src/rl/maps.ts`, the arena and the trainer ship in no bundle
328 * at all. It fits the 92,000 / 42,000 #164 raised with 1,127 raw and 891
329 * gzipped to spare, so no ceiling moved; neither other bundle changed a byte.
330 * Measured 2026-09-20, with #113 and #189 merged in.
331 * #185 gave a bot something to do when its gun is empty — `dry`, `nextWeapon`
332 * and `knifeRush` in `src/client/bots.ts`, about thirty lines — and the shooter
333 * grew 529 raw / 181 gzipped bytes for it, measured on top of #190: 91,492 →
334 * 92,021 raw and 41,593 → 41,774 gzipped. That is twenty-one bytes past the raw
335 * half of the 92,000 / 42,000 #164 set, and the gzipped half had 226 left,
336 * which is not headroom anybody can land a feature in: the ceiling is 96,000 /
337 * 43,500 here, today's size plus about four per cent of each, on the same
338 * reasoning #164 raised the last one. Neither other bundle moved a byte —
339 * nothing outside `src/client/` changed. Measured 2026-09-20, with #190 merged
340 * in.
341 * #173 made the match id a thing a person can paste: `src/matchid.ts` — the one
342 * door that says what an id is, what a playing of one is keyed by and how to
343 * read the match back out of that key — plus the field the page validates
344 * again, the line that answers a typo, and the id, the map and the share link
345 * the frame writes out when a match starts. The app grew 728 raw / 350 gzipped
346 * bytes for all of it, measured on top of main with #185, #195 and #196 in it:
347 * 92,018 → 92,746 raw and 41,774 → 42,124 gzipped. It fits the 96,000 / 43,500
348 * #185 raised with 2,254 raw and 1,376 gzipped bytes to spare, so no ceiling
349 * moved here — this branch asked for 95,000 before #185 landed and took #185's
350 * larger number instead of arguing with it. The id module ships in the bundle
351 * on purpose: the page has to validate what a person typed with the same shape
352 * the server accepts, and a second copy of that regular expression is how two
353 * of them drift apart. Neither other bundle moved a byte: nothing outside
354 * `src/client/`, `src/pages/game.ts` and the new module changed, and neither of
355 * the other two games imports any of them. Measured 2026-09-20.
356 * #102 made a result that did not land say so and be tried again —
357 * `src/client/pending.ts`, which is the policy and the four sentences, and the
358 * one pending round the frame keeps beside it — and the app grew 875 raw / 601
359 * gzipped bytes for it: 92,839 → 93,714 raw and 41,938 → 42,539 gzipped. Most
360 * of that is the sentences themselves, which a minifier cannot shorten. It fits
361 * the 96,000 / 43,500 #185 raised with 2,286 raw and 961 gzipped bytes to
362 * spare, so no ceiling moved. Neither other bundle changed a byte: the range
363 * posts nothing and ships none of this. Measured 2026-09-20.
364 * #135 made the round audible — a footstep as a foot lands, the bomb's beep
365 * quickening as the fuse runs down, the plant, the cut wire and a body going
366 * down — and the app grew 1,516 raw / 467 gzipped bytes for it: 93,714 → 95,230
367 * raw and 42,539 → 43,006 gzipped. That is five recipes in `src/client/audio.ts`
368 * and the distances they are heard over, the stride clock and the four emitters
369 * in `src/client/world.ts`, and the fuse's cadence in `src/client/bomb.ts`; no
370 * module was added and nothing was downloaded, because a sound here is arithmetic
371 * like everything else. It fits the 96,000 / 43,500 #185 raised with 770 raw and
372 * 494 gzipped bytes to spare, so the ceiling is held — but that is now the
373 * thinnest headroom on this page, and the next thing the shooter gains will want
374 * it raised. Neither other bundle moved a byte: nothing outside `src/client/`
375 * changed. Measured 2026-09-21.
376 * #148 gave a generated map a name you can keep — `src/mapname.ts`, the one door
377 * that says what a name is and what seed it means, plus the field the page
378 * validates with it, the line that names the map before Play and the link that
379 * copies it — and the app grew 1,358 raw / 531 gzipped bytes for it: 95,230 →
380 * 96,588 raw and 43,006 → 43,537 gzipped. That is 588 bytes past the raw half of
381 * the 96,000 / 43,500 #185 set and 37 past the gzipped half, which is the raise
382 * #135 said the next feature would want: the ceiling is 100,000 / 45,000 here,
383 * today's size plus about three per cent of each, on the same reasoning #164 and
384 * #185 raised the last two. The name module ships in the bundle for the reason
385 * `src/matchid.ts` does — the page validates what a person typed with the same
386 * grammar the server accepts a query with, and a second copy of that grammar is
387 * how two of them drift apart — and most of the rest is sentences a minifier
388 * cannot shorten. Neither other bundle moved a byte: `?map=` is the shooter's
389 * parameter and the other two games neither read it nor import any of this.
390 * Measured 2026-09-21.
391 * #149 drew that map on the menu — `src/client/preview.ts`, which is the
392 * radar's own `drawPlan` given a whole level, the two spawns on top of it and
393 * the sentence a screen reader gets instead — and the app grew 1,368 raw / 536
394 * gzipped bytes for it: 96,588 → 97,956 raw and 43,537 → 44,073 gzipped. Almost
395 * none of that is drawing: the projection and the merged floor rectangles were
396 * already in the bundle for the radar, and what is new is the debounce in the
397 * frame loop, the spawn marks, and the compass words, which a minifier cannot
398 * shorten. It fits the 100,000 / 45,000 #148 set with 2,044 raw and 927 gzipped
399 * bytes to spare, so the ceiling is held — and it is still the thinnest headroom
400 * on this page. Neither other bundle moved a byte: nothing outside
401 * `src/client/` and `src/pages/game.ts` changed. Measured 2026-09-21.
402 * #216 gave the example the renderer it advertises: two mesh recipes, two atlas
403 * layers and a frame of two instances and one line, all in
404 * `src/engine/example.ts`, drawn by the engine's WebGL2 renderer with the 2D
405 * plan kept as the fallback. The example more than doubled for it — 9,735 →
406 * 21,115 raw and 4,618 → 9,189 gzipped — and almost none of those 11,380 bytes
407 * are the game's: the recipes are about forty lines, and what arrived behind
408 * them is `renderer.ts`, `mesh.ts`, `texture.ts` and `scene.ts`, which a tool
409 * set ships whole. That is the price of the headline, and it is the same price
410 * the range paid. Its ceiling could not hold and is 23,500 / 10,200 here,
411 * today's size plus about eleven per cent, which is what every other number on
412 * this page is. The other two grew 46 raw / 33 gzipped (app) and 34 / 24
413 * (range) for one thing: `RendererAssets.near` and `RendererAssets.far`, the
414 * clip planes a game now chooses, because a room four metres wide is invisible
415 * behind a near plane of six Source units. Both fit, so neither moved. Measured
416 * 2026-09-21: app 98,617 raw / 44,304 gzipped, range 34,810 / 14,569,
417 * example 21,115 / 9,189.
418 * #214 took the trained weights out of the entry bundle: they are
419 * `src/client/weights.json`, this game's first `Asset`, copied to
420 * `static/weights.<hash>.json` by the build and fetched at boot. So this is the
421 * first line on this page where a ceiling comes *down*: app 98,617 → 89,037 raw
422 * and 44,304 → 35,268 gzipped, which is 9,580 raw and 9,036 gzipped gone, and
423 * the ceiling is 100,000/45,000 → 98,000/39,000, today's size plus about ten
424 * per cent like every other number here. Nothing was deleted to get it — 10,878
425 * raw and 9,518 gzipped bytes of packed literal left, and about 1,900 raw came
426 * back as the reader that validates a downloaded policy, the two sentences the
427 * menu says about one that did not arrive, and the load itself. The weights
428 * became an asset of their own at 11,120 raw / 8,251 gzipped, under a ceiling
429 * of 12,300 / 9,100 on the same rule; the JSON is 242 raw bytes larger than the
430 * literal it replaced and 1,267 gzipped bytes smaller, because a bundle
431 * compresses around base64 worse than a file that is almost nothing else. A
432 * player downloads 100,157 raw / 43,519 gzipped across the two on a cold visit
433 * — 785 gzipped bytes less than before — and on the next deploy of the game
434 * downloads only the bundle, because the weights are named by their own
435 * content and change when somebody retrains. Neither other bundle moved a
436 * byte: range 34,810 raw / 14,569 gzipped and example 21,115 / 9,189, the same
437 * numbers #216 measured, and both declare no assets at all. Measured
438 * 2026-09-21 with #216 merged in.
439 * #144 gave the shooter an end: `src/client/summary.ts`, which turns the world
440 * a match finished in into the numbers that are posted and the words and rows
441 * that are read, plus the overlay the page renders for it and the frame that
442 * fills it in. The app grew 2,051 raw / 803 gzipped bytes for it — 89,174 →
443 * 91,225 raw and 35,324 → 36,127 gzipped — and most of that is strings a
444 * minifier cannot shorten: the two sides' names, the sentences over the score,
445 * the table's headings and the one line about whether the result reached the
446 * leaderboard. The HUD's Tab board lost its own sort and its own row
447 * formatting to that module, which is why the feature costs about two
448 * kilobytes rather than three. It fits the 98,000 / 39,000 #214 set with 6,775
449 * raw and 2,873 gzipped to spare, so no ceiling moved. Neither other bundle
450 * changed a byte: nothing outside `src/client/` and `src/pages/game.ts` did.
451 * Measured 2026-09-21.
452 * #165 put the third grenade in the game — a smoke, which is the first thing
453 * here that changes what a bot can *see*: a canister that waits until it has
454 * come to rest, a `Cloud` the world keeps, `segmentSphere` behind the engine's
455 * door, the ball `canSee` traces against, the lumpy mesh it is drawn as, the
456 * wash the HUD puts over an eye inside one, the step `bots.ts` bends around
457 * one, a sound, a key and a row in the weapon table. The app grew 3,067 raw /
458 * 1,107 gzipped bytes for all of it: 95,598 → 98,665 raw and 37,788 → 38,895
459 * gzipped. That is 665 bytes past the raw half of the 98,000 / 39,000 #214
460 * brought *down*, and the gzipped half had 105 left, which is not headroom
461 * anybody can land a feature in — so the ceiling is 103,000 / 41,000 here,
462 * today's size plus about four per cent of raw and five of gzip, on the same
463 * reasoning #164, #185 and #148 raised the last three. Neither other bundle
464 * moved a byte: the range is 34,810 raw / 14,568 gzipped, the same numbers
465 * #216 measured, and nothing outside `src/client/`, `src/engine/trace.ts` and
466 * `src/pages/game.ts` changed — the range and the example ship no world, no
467 * weapons and no cloud. Measured 2026-09-21.
468 * #250 moved the buy panel out of the entry into `src/client/buy.ts` — the
469 * shelf, the prices and which tiles a wallet and a round leave pressable — and
470 * the app grew 199 raw / 71 gzipped bytes for it: 98,665 → 98,864 raw and
471 * 38,895 → 38,966 gzipped, measured on top of #165 rather than under it. It is
472 * a move and not a feature, so those bytes are the seam itself: a module
473 * boundary the minifier will not inline across, the object the frame lends the
474 * panel, and the hint sentence and the shelf now named rather than written
475 * inline. Nothing was added to the shelf to get them — the smoke #165 put in
476 * the weapon table arrived on the panel through `buyable`, which is the seam
477 * working rather than a line anybody wrote. It fits the 103,000 / 41,000 #165
478 * set with 4,136 raw and 2,034 gzipped to spare, so no ceiling moved. Neither
479 * other bundle changed a byte: nothing outside `src/client/` did. Measured
480 * 2026-09-21.
481 * #251 moved the result posting out of the entry into `src/client/report.ts` —
482 * the world turned into the bytes the server takes, the one pending round, the
483 * retry and its backoff — and the app grew 117 raw / 83 gzipped bytes for it:
484 * 98,864 → 98,981 raw and 38,966 → 39,049 gzipped, measured on top of #250. It
485 * is a move and not a feature, and those bytes are the seam again: a module
486 * boundary the minifier will not inline across, the object the frame lends the
487 * reporter, and the endpoint now named rather than written inline. It fits the
488 * 103,000 / 41,000 #165 set with 4,019 raw and 1,951 gzipped to spare, so no
489 * ceiling moved. Neither other bundle changed a byte: the range and the example
490 * post nothing and ship none of this. Measured 2026-09-21.
80 * Moving one is allowed and is never silent. `bun run scripts/build.ts` prints
81 * what it built — raw, gzipped and the headroom left under each ceiling — and a
82 * pull that moves a number copies it from that run and says what was added, what
83 * it cost and why the bytes are worth it; a ceiling comes down the same way when
84 * bytes leave. How each number got where it is belongs to the pulls that moved
85 * it, not to this file: each entry says which pull last set its ceiling and for
86 * what, and the rest is `git log`.
87 *
88 * Two standing facts behind most measurements: a tool set is bundled whole, so a
89 * game using three of a module's tools ships all of them; and a pull that only
90 * moves code between modules still shifts a bundle by a handful of bytes,
91 * because the minifier hands out different short names over a module graph that
92 * changed shape.
49193 */
49294 export const GAMES: readonly Game[] = [
49395 {
@@ -495,11 +97,13 @@ export const GAMES: readonly Game[] = [
49597 href: "/",
49698 label: "cs",
49799 entry: "src/client/main.ts",
100 /** Raised by #165, which put a smoke grenade — and a cloud a bot cannot see through — in the game. */
498101 ceiling: { raw: 103_000, gzip: 41_000 },
499102 assets: [
500103 {
501104 name: "weights",
502105 source: "src/client/weights.json",
106 /** Set by #214, which took the packed policy out of the entry bundle and made it this file. */
503107 ceiling: { raw: 12_300, gzip: 9_100 },
504108 },
505109 ],
@@ -513,6 +117,7 @@ export const GAMES: readonly Game[] = [
513117 href: "/range",
514118 label: "range",
515119 entry: "src/range/main.ts",
120 /** Raised by #63, which gave the range a person to be: its tuning, its hull and the engine's solver. */
516121 ceiling: { raw: 37_000, gzip: 15_500 },
517122 assets: [],
518123 blurb:
@@ -525,6 +130,7 @@ export const GAMES: readonly Game[] = [
525130 href: "/example",
526131 label: "example",
527132 entry: "src/example/main.ts",
133 /** Raised by #216, which gave the example the WebGL2 renderer it advertises — a tool set ships whole. */
528134 ceiling: { raw: 23_500, gzip: 10_200 },
529135 assets: [],
530136 blurb:

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs