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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/182-the-registry-stops-being-a-changelog-src → main 2 commits fork
4 files
changed +204
−449
README.md +26 −18
| @@ -1139,26 +1139,34 @@ never checked itself. | ||
| 1139 | 1139 | stops the server before it listens**, naming the directory it could not write, |
| 1140 | 1140 | and so does a game that resolved to no bundle or to two: a failed deploy whose |
| 1141 | 1141 | 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: | |
| 1144 | 1147 | |
| 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 | ||
| 1150 | 1156 | |
| 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. | |
| 1162 | 1170 | - No dependencies. Nothing is installed to run this. |
| 1163 | 1171 | |
| 1164 | 1172 | 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 { | ||
| 11 | 11 | isNamed, |
| 12 | 12 | GZIP_LEVEL, |
| 13 | 13 | isBundleOf, |
| 14 | line, | |
| 14 | NOTE, | |
| 15 | 15 | overshoot, |
| 16 | row, | |
| 16 | 17 | sizes, |
| 18 | table, | |
| 17 | 19 | weigh, |
| 18 | 20 | write, |
| 21 | type Built, | |
| 22 | type Ceiling, | |
| 19 | 23 | type Entry, |
| 20 | 24 | } from "./build.ts"; |
| 21 | 25 | 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 | ||
| 244 | 248 | } |
| 245 | 249 | }); |
| 246 | 250 | |
| 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", () => { | |
| 248 | 261 | const code = `console.log("hi");\n`; |
| 249 | 262 | 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 }); | |
| 254 | 336 | }); |
scripts/build.ts +66 −7
| @@ -10,6 +10,10 @@ | ||
| 10 | 10 | * read, weigh or serve another way. Each entry carries the size it may not pass, |
| 11 | 11 | * and `build.test.ts` builds and weighs it. |
| 12 | 12 | * |
| 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 | * | |
| 13 | 17 | * A bundle and an asset are the same thing here — bytes filed under a name and |
| 14 | 18 | * their own content hash, swept when they change and held to a ceiling — and |
| 15 | 19 | * 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 }; | ||
| 41 | 45 | /** |
| 42 | 46 | * What is built, projected from the registry in `src/games.ts`: a game is |
| 43 | 47 | * 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. | |
| 45 | 49 | */ |
| 46 | 50 | export const ENTRIES: Entry[] = GAMES.map(({ name, entry, ceiling }) => ({ name, entry, ceiling })); |
| 47 | 51 | |
| @@ -221,10 +225,58 @@ export async function write( | ||
| 221 | 225 | return built; |
| 222 | 226 | } |
| 223 | 227 | |
| 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 | ]; | |
| 228 | 280 | } |
| 229 | 281 | |
| 230 | 282 | /** |
| @@ -235,10 +287,17 @@ export function line({ file, code, ceiling }: Built, where = "static"): string { | ||
| 235 | 287 | * process for the bundles because the engine may import nothing outside itself. |
| 236 | 288 | * |
| 237 | 289 | * 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. | |
| 239 | 292 | */ |
| 240 | 293 | if (import.meta.main) { |
| 241 | 294 | const asked = process.argv[2]; |
| 242 | 295 | 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 | } | |
| 244 | 303 | } |
src/games.ts +24 −418
| @@ -69,425 +69,27 @@ export type Game = { | ||
| 69 | 69 | /** |
| 70 | 70 | * In the order the header lists them, which is also the order they are built. |
| 71 | 71 | * |
| 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. | |
| 86 | 79 | * |
| 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. | |
| 491 | 93 | */ |
| 492 | 94 | export const GAMES: readonly Game[] = [ |
| 493 | 95 | { |
| @@ -495,11 +97,13 @@ export const GAMES: readonly Game[] = [ | ||
| 495 | 97 | href: "/", |
| 496 | 98 | label: "cs", |
| 497 | 99 | 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. */ | |
| 498 | 101 | ceiling: { raw: 103_000, gzip: 41_000 }, |
| 499 | 102 | assets: [ |
| 500 | 103 | { |
| 501 | 104 | name: "weights", |
| 502 | 105 | source: "src/client/weights.json", |
| 106 | /** Set by #214, which took the packed policy out of the entry bundle and made it this file. */ | |
| 503 | 107 | ceiling: { raw: 12_300, gzip: 9_100 }, |
| 504 | 108 | }, |
| 505 | 109 | ], |
| @@ -513,6 +117,7 @@ export const GAMES: readonly Game[] = [ | ||
| 513 | 117 | href: "/range", |
| 514 | 118 | label: "range", |
| 515 | 119 | 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. */ | |
| 516 | 121 | ceiling: { raw: 37_000, gzip: 15_500 }, |
| 517 | 122 | assets: [], |
| 518 | 123 | blurb: |
| @@ -525,6 +130,7 @@ export const GAMES: readonly Game[] = [ | ||
| 525 | 130 | href: "/example", |
| 526 | 131 | label: "example", |
| 527 | 132 | entry: "src/example/main.ts", |
| 133 | /** Raised by #216, which gave the example the WebGL2 renderer it advertises — a tool set ships whole. */ | |
| 528 | 134 | ceiling: { raw: 23_500, gzip: 10_200 }, |
| 529 | 135 | assets: [], |
| 530 | 136 | blurb: |
new above
offline