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

forked from trevato/cs · proposes

A third game, served: /example proves a game now costs one entry and one page #119

Merged opened this

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

Merged

vesper/cs-engine:vesper/80-a-third-game-served-example-proves-a-gam → main 2 commits fork

  • Merged. vesper/80-a-third-game-served-example-proves-a-gam was merged into main as c93a8c0.

Review

Sign in to review this change.
18 files changed +981 −30
README.md +74 −15
@@ -1,6 +1,6 @@
11 # cs-engine
22
3 A browser game engine with two games on it. The first, at `/`, is **cs**: a
3 A browser game engine with three games on it. The first, at `/`, is **cs**: a
44 Counter-Strike-shaped defusal shooter, five against five on one map — bomb
55 rounds, a buy menu and an economy, bots that walk to a site and shoot back —
66 and the leaderboard of the matches people played. The second, at `/range`, is
@@ -9,8 +9,12 @@ built only out of the engine the shooter was taken apart into: a shooting
99 engine's boxes, a thirty second clock and a score, with the fixed step and the
1010 controls under it — and it is **drawn by the engine's renderer**: its own room,
1111 its own plates and its own tracer, out of an atlas and two meshes it painted
12 and built itself. The engine in
13 `src/engine/` is the point of the repository; the shooter is what proves it.
12 and built itself. The third, at `/example`, is the **example**: the guide's own
13 game — one room seen from above, one plate, thirty seconds and a score, drawn
14 in 2D through the engine's `overlay` and nothing else — served to say what
15 building on this engine actually costs. The engine in
16 `src/engine/` is the point of the repository; the shooter is what proves it,
17 and the example is the price tag.
1418
1519 ```sh
1620 PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts # run it here
@@ -108,6 +112,41 @@ you stand at the firing line and turn on the spot. A browser with no WebGL2 keep
108112 on the 2D horizon it shipped with — a plate as a square drawn from the angle it
109113 subtends — and the panel says which of the two you are looking at.
110114
115 At `/example` the smallest game on the engine runs on the game's own table:
116 <kbd>←</kbd> and <kbd>→</kbd> turn, <kbd>WASD</kbd> walks, <kbd>Space</kbd>
117 fires one shot a press, <kbd>Enter</kbd> — or the Start button — begins thirty
118 seconds. You are looking at the room from above: the blue dots are where the
119 shot goes, the yellow bar on the far wall is the plate, and a hit puts up the
120 next one. On a touch screen the four buttons do the same.
121
122 ### What a third game cost
123
124 The example is the answer to "what does building on this engine take?", so it
125 is written down as a number. The game itself was already there —
126 `src/engine/example.ts`, the 79 lines the engine's guide is written out of,
127 which `guide.test.ts` executes block by block — and `/example` did not copy a
128 line of it. What it added was:
129
130 | what | how much |
131 | --------------------------------------- | ------------------------------- |
132 | `src/example/main.ts` (the wiring) | 112 lines |
133 | `src/example/plan.ts` (the drawing) | 86 lines |
134 | `src/pages/example.ts` (its page) | 76 lines |
135 | `src/games.ts` (its registry entry) | 10 lines |
136 | `src/server.ts` (one line in `RENDER`) | 1 line |
137 | `static/example.<hash>.js` (its bundle) | 8,754 raw / 4,229 gzipped bytes |
138
139 That is 198 lines of game, an eighth of the shooter's bundle, and no new branch
140 in the server. What it did **not** have to write: the loop and its fixed step,
141 the bindings-table reader, the touch buttons, the pointer handling, the ray
142 against a box, the seeded random, the vector maths, the panel, the bar, the
143 crosshair, the `m:ss` clock, the text scaling, the headless page its tests boot
144 on, the document it is served in, the header's nav, the cross-links on the
145 other two pages and its line in the 404 — the last four because they are
146 projections of `src/games.ts` and nobody edited them. The 2D is the point: no
147 renderer, no atlas, no mesh and no sound ship in that bundle, and
148 `src/example/bundle.test.ts` reads the committed file to prove it.
149
111150 ## Shape
112151
113152 - `src/engine/` — the part a second game could use, and the rule is that it never
@@ -191,9 +230,9 @@ subtends — and the panel says which of the two you are looking at.
191230 - `src/engine/example.ts` — the smallest game on the package, and the guide's
192231 code made runnable: under eighty lines with one import, a room to stand in, a
193232 plate to shoot, a bindings table, a 60 Hz tick and a `step(world, dt, held)`
194 that takes plain data and gives plain data back. It has no page, no renderer
195 and nothing saved — `src/range/` is the real second game — and
196 `example.test.ts` plays it headlessly: a seeded run scores the same twice, a
233 that takes plain data and gives plain data back. It has no renderer and
234 nothing saved, and it is documentation first: `src/example/` is what gives it
235 a page. `example.test.ts` plays it headlessly: a seeded run scores the same twice, a
197236 shot down the target's bearing hits and one twenty degrees off misses, and a
198237 second of elapsed time is sixty ticks however the frames are cut.
199238 - `src/client/` — the game, in modules named for one thing each: `level` (what a
@@ -336,6 +375,22 @@ subtends — and the panel says which of the two you are looking at.
336375 `pin.test.ts` — so a renderer, an atlas or a movement tuning that changes the
337376 game says so, naming the second of the run where it changed. `src/pages/range.ts` is its page: its own title, prose and overlay
338377 through the same shell, with no database, no endpoint and no cookie behind it.
378 - `src/example/` — the third game, at `/example`, and the wiring of one and
379 nothing else: `main.ts` finds the canvas, hands the engine's controls the
380 game's own bindings table plus the one key that starts a run, runs the fixed
381 loop and calls the guide's game's `step`; `plan.ts` draws the room from above
382 through the engine's `overlay` alone — filled rectangles for the floor, the
383 plate and the aim, a crosshair for you, a bar for the countdown and the
384 overlay's own `m:ss`. 198 lines between them, 2D on one canvas: no WebGL, no
385 atlas, no sound, no cookie. The game is shared rather than copied, which is
386 the one thing this pull had to buy: the guide's game is not a module of the
387 package and is not behind the door, so `src/engine/boundary.test.ts` lets
388 `src/example/` — and only `src/example/` — name `src/engine/example.ts` by
389 path. `index.ts` is untouched and the engine's surface did not move.
390 `boot.test.ts` boots the entry on the engine's headless page and
391 `bundle.test.ts` boots the committed bundle and reads it for what is *not* in
392 it (`webgl2`, `AudioContext`, `Math.tanh`): a door is only worth having if
393 importing it costs what you use.
339394 - `src/rl/` — training, which no game module imports: `arena` is the environment
340395 (a real duel on de_forge with the round and the economy taken out) and `ppo` is
341396 the algorithm, with its backward pass checked against finite differences.
@@ -343,8 +398,8 @@ subtends — and the panel says which of the two you are looking at.
343398 *shooter's* training and `src/independence.test.ts` counts it as the shooter:
344399 it reads `src/client/` module by module because that game is the environment,
345400 and for the same reason it may not read `src/range/`.
346 - `src/independence.test.ts` — **two games, one engine, no sharing, and this is
347 the test that says so**, in both directions: a game's sources may not import
401 - `src/independence.test.ts` — **three games, one engine, no sharing, and this
402 is the test that says so**, in both directions: a game's sources may not import
348403 another game's — the shooter reaching into the range fails here by name
349404 (`src/client/world.ts imports "../range/targets.ts"; a game may not import
350405 another game`) exactly as the range reaching into the shooter does — and the
@@ -355,7 +410,8 @@ subtends — and the panel says which of the two you are looking at.
355410 `src/engine/boundary.test.ts` rather than a second copy of them — a test may
356411 read another test's helpers, which is the one path past the door, because a
357412 `.test.ts` is in no bundle. Who owns which directory comes from the registry
358 (`dirname(entry)`), so a third game is covered by the entry that adds it.
413 (`dirname(entry)`), so the third game was covered by the entry that added it
414 and this file gained no rule for it.
359415 `src/server.ts` and `src/pages/` belong to no game and are held to neither
360416 rule: the app is allowed to know about every game on it.
361417 - `src/client/replay.ts` — the golden digest: a seeded round and a seeded duel
@@ -422,20 +478,23 @@ subtends — and the panel says which of the two you are looking at.
422478 that names that page function — because a page is a function and cannot be
423479 data. The build, the header's nav, the cross-links on every other page and the
424480 404 all follow on their own. No inline script; the CSP is `default-src 'self'`.
425 `/range` is that recipe followed once.
481 `/range` is that recipe followed once and `/example` is it followed twice —
482 the third game added one entry, one page and one line in `RENDER`, and the
483 header, the 404 and the other two pages grew it without being touched.
426484 - `scripts/build.ts` bundles every entry into `static/<name>.<hash>.js`,
427485 committed and cached for a year. Each entry sweeps
428486 up only its own stale bundles, so one
429487 game's rebuild never deletes another's, and `bun test` fails if any committed
430 bundle is stale — today that is `app` (the shooter) and `range`. The server
488 bundle is stale — today that is `app` (the shooter), `range` and `example`. The server
431489 resolves the script for an entry by that exact name.
432490 - **Every bundle has a ceiling.** Today, and `bun run scripts/build.ts` prints
433491 these next to each file name:
434492
435 | bundle | raw | ceiling | gzipped | ceiling |
436 | ------- | ------ | ------- | ------- | ------- |
437 | `app` | 70,576 | 75,400 | 32,862 | 34,900 |
438 | `range` | 26,658 | 29,400 | 11,456 | 12,700 |
493 | bundle | raw | ceiling | gzipped | ceiling |
494 | --------- | ------ | ------- | ------- | ------- |
495 | `app` | 71,646 | 75,400 | 33,273 | 34,900 |
496 | `range` | 27,268 | 29,400 | 11,674 | 12,700 |
497 | `example` | 8,754 | 9,700 | 4,229 | 4,700 |
439498
440499 The ceilings are today's sizes plus about 10%. Gzip is measured at level 9
441500 and only at level 9, so the number does not move between a laptop and the
scripts/build.test.ts +1
@@ -39,6 +39,7 @@ test("the entry table is what the app ships today, each with the size it may not
3939 expect(ENTRIES).toEqual([
4040 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
4141 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 29_400, gzip: 12_700 } },
42 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 9_700, gzip: 4_700 } },
4243 ]);
4344 // And it is the registry projected, not a second list beside it: a game's
4445 // source and its ceiling are written down once, in `src/games.ts`.
src/engine/GUIDE.md +8 −4
@@ -11,8 +11,10 @@ any use, in the order you would actually write it.
1111 The engine is not a framework. It never calls your game — your game calls it,
1212 from a frame it owns, with a page it opened and numbers it chose. Two real games
1313 do that here: the shooter in `src/client/` and the range in `src/range/`. The
14 example is neither — it has no page, no renderer and nothing saved — and that is
15 why it fits in one file and a test can play it in a millisecond.
14 example is a third, and the smallest — it has no renderer and nothing saved,
15 which is why it fits in one file and a test can play it in a millisecond — and
16 `src/example/` is the 198 lines that give it a page at `/example`, so the code
17 on this page is also code you can go and click.
1618
1719 **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's,
1820 exported from `src/engine/index.ts`. A game's own name carries its module:
@@ -51,8 +53,10 @@ Two more things and a real game is reachable: one entry in the registry in
5153 ceiling — and a page that server-renders the markup the entry looks up by id
5254 (`src/pages/range.ts`), named in the one map in `src/server.ts` that turns a
5355 game's name into its page function. The build, the header's nav and the 404
54 follow from the entry. The example has neither, on purpose: it is read and
55 played by a test, never shipped.
56 follow from the entry. The example's own are `src/example/main.ts` and
57 `src/pages/example.ts`, and they are worth reading next to this file: they are
58 the whole of what the two paragraphs above cost, and nothing in
59 `src/engine/example.ts` changed to get them.
5660
5761 ### 2. A host
5862
src/engine/boundary.test.ts +35 −2
@@ -92,18 +92,35 @@ export function outsideSources(dirs = OUTSIDE): string[] {
9292
9393 /**
9494 * Why this file outside the engine may not import this specifier, or null if it
95 * may. Only one path into the engine is a path: `index.ts`, the door. The one
96 * exception is a test reading another test's helpers — `src/independence.test.ts`
95 * may. Only one path into the engine is a path: `index.ts`, the door. There are
96 * two exceptions, and both are about things that are not modules of this
97 * package.
98 *
99 * The first is a test reading another test's helpers — `src/independence.test.ts`
97100 * holds both games apart with the walk and the regular expression in this file
98101 * rather than a second copy of them — because the door is about what ships, and
99102 * a `.test.ts` is in no bundle.
103 *
104 * The second is `example.ts`, named by `src/example/`, the entry that serves it.
105 * The example is not behind the door and is not a module of the engine — it is
106 * a *game*, written on this package the way a game outside it is, which is why
107 * `notAModule` above already says so and why it imports the door itself. A game
108 * cannot be shared by being re-exported as API; the only way `/example` could
109 * serve the guide's own code rather than a copy of it was for the entry to name
110 * the file. So the exception is written as narrowly as it is true: this one
111 * file, from that one directory. The engine's surface did not move for it —
112 * `index.ts` offers exactly what it offered before.
100113 */
114 const GUIDE_GAME = "example.ts";
115 const GUIDE_GAME_ENTRY = "src/example/";
116
101117 export function reachesPastTheDoor(file: string, specifier: string): string | null {
102118 if (!specifier.startsWith(".")) return null;
103119 const target = resolve(ROOT, join(file, ".."), specifier);
104120 const inside = relative(ENGINE, target);
105121 if (inside === "" || inside.startsWith("..")) return null;
106122 if (inside === "index.ts") return null;
123 if (inside === GUIDE_GAME && file.startsWith(GUIDE_GAME_ENTRY)) return null;
107124 if (file.endsWith(".test.ts") && inside.endsWith(".test.ts")) return null;
108125 return `${file} imports "${specifier}"; the engine's door is src/engine/index.ts`;
109126 }
@@ -195,6 +212,22 @@ test("the door rule is about the door and nothing else", () => {
195212 expect(reachesPastTheDoor("src/independence.test.ts", "./engine/math.ts")).toMatch(/door/);
196213 });
197214
215 test("the guide's game is named by the one entry that serves it, and by nobody else", () => {
216 // `src/example/` is the page, the canvas and the bindings table around
217 // `example.ts`; the game itself is shared rather than copied.
218 expect(reachesPastTheDoor("src/example/main.ts", "../engine/example.ts")).toBeNull();
219 expect(reachesPastTheDoor("src/example/plan.ts", "../engine/example.ts")).toBeNull();
220 expect(reachesPastTheDoor("src/example/boot.test.ts", "../engine/example.ts")).toBeNull();
221 // And nobody else may: not the other games, not the server, not a page.
222 expect(reachesPastTheDoor("src/client/world.ts", "../engine/example.ts")).toMatch(/door/);
223 expect(reachesPastTheDoor("src/range/main.ts", "../engine/example.ts")).toMatch(/door/);
224 expect(reachesPastTheDoor("src/pages/example.ts", "../engine/example.ts")).toMatch(/door/);
225 // The licence is that one file, not the rest of the engine.
226 expect(reachesPastTheDoor("src/example/main.ts", "../engine/math.ts")).toMatch(/door/);
227 expect(reachesPastTheDoor("src/example/main.ts", "../engine/overlay.ts")).toMatch(/door/);
228 expect(reachesPastTheDoor("src/example/main.ts", "../engine/index.ts")).toBeNull();
229 });
230
198231 test("every module in the engine is behind the door, and the door holds nothing else", () => {
199232 const door = readFileSync(join(ENGINE, "index.ts"), "utf8");
200233 const behind = new Set(specifiersOf(door).map((s) => s.replace(/^\.\//, "")));
src/engine/page.test.ts +10 −5
@@ -309,19 +309,24 @@ test("the module has no timer of its own and touches no global", () => {
309309 expect(code).toContain("export function fakePage");
310310 });
311311
312 test("neither committed bundle carries the double", () => {
312 test("no committed bundle carries the double", () => {
313313 const dir = join(ROOT, "static");
314314 const bundles = readdirSync(dir).filter((file) => file.endsWith(".js"));
315 // Both games' entries, so this is not a test that passes on an empty directory.
316 expect(bundles.length).toBeGreaterThanOrEqual(2);
315 // Every game's entry, so this is not a test that passes on an empty directory.
316 expect(bundles.length).toBeGreaterThanOrEqual(3);
317317 for (const file of bundles) {
318318 const code = readFileSync(join(dir, file), "utf8");
319 // The reader is alive: these files are what it thinks they are.
319 // The reader is alive: these files are what it thinks they are. What every
320 // one of them has is a canvas — not WebGL, which `/example` proves a game
321 // here can do without, and which this file would otherwise be pinning on
322 // every game that ever ships.
320323 expect(code.length).toBeGreaterThan(1000);
321 expect(code).toContain("webgl2");
324 expect(code).toContain("getContext");
322325 // And no byte of this module is in them — the sentence it carries, and the
323326 // one pattern only it has.
324327 expect(code).not.toContain(HEADLESS);
325328 expect(code).not.toContain(`class="([a-z-]+)"`);
326329 }
330 // The two games that draw in three dimensions still do.
331 expect(bundles.filter((f) => readFileSync(join(dir, f), "utf8").includes("webgl2")).length).toBe(2);
327332 });
src/example/boot.test.ts added +224
@@ -0,0 +1,224 @@
1 /**
2 * The entry itself, booted against the engine's headless page: the ids it looks
3 * up, the keys it binds, the frames it asks for and what it draws when one
4 * runs. Nothing here is a browser and nothing here is timed by a real clock —
5 * the test decides when a frame happens and how long it took.
6 *
7 * The page comes from `src/engine/index.ts` through `stage.test-helper.ts`, so
8 * this game writes no stub document of its own. What is tested here is the
9 * wiring and only the wiring: the rules — a seeded plate, a ray against a box,
10 * one shot per press — are the guide's game's, and `src/engine/example.test.ts`
11 * already runs those with no page in the room at all.
12 */
13
14 import { expect, test } from "bun:test";
15 import { KEYS, TICK } from "../engine/example.ts";
16 import { page, type ExamplePage } from "./stage.test-helper.ts";
17
18 // The entry boots itself when it is imported, so it is imported into a page
19 // that is still loading: then every test below boots a fresh one on purpose.
20 const loading = page();
21 loading.document.readyState = "loading";
22 const undoLoading = loading.install();
23 const { BINDINGS, RUN, boot } = await import("./main.ts");
24 undoLoading();
25
26 /** A booted page, and the undo that puts the globals back. */
27 function booted(options: { has2d?: boolean; coarse?: boolean } = {}): { p: ExamplePage; undo: () => void } {
28 const p = page(options);
29 const undo = p.install();
30 boot();
31 return { p, undo };
32 }
33
34 type Call = [string, ...unknown[]];
35
36 /**
37 * One frame, and the calls it alone made: the recorder keeps every frame ever
38 * drawn, and almost everything here is a question about one of them.
39 */
40 function drawn(p: ExamplePage, ms: number): { calls: Call[]; texts: string[] } {
41 const all = p.scene.paint?.calls ?? [];
42 const from = all.length;
43 p.step(ms);
44 const calls = all.slice(from);
45 return { calls, texts: calls.filter((c) => c[0] === "fillText").map((c) => String(c[1])) };
46 }
47
48 /**
49 * Where the aim dots are, as one string. They are the only 2×2 rectangles on
50 * the canvas — the room, the plate and the countdown bar are all larger — so
51 * this is the drawn answer to "which way am I facing, and from where".
52 */
53 const aim = (calls: Call[]): string =>
54 calls.filter((c) => c[0] === "fillRect" && c[3] === 2 && c[4] === 2).map((c) => `${c[1]},${c[2]}`).join(" ");
55
56 /** Where the player's crosshair was centred: the one path this page strokes. */
57 const marker = (calls: Call[]): [number, number] => {
58 const move = calls.find((c) => c[0] === "moveTo") as Call | undefined;
59 return [Number(move?.[1] ?? 0), Number(move?.[2] ?? 0)];
60 };
61
62 test("the table it binds is the game's own, plus the one key the page adds", () => {
63 // The game says what a key does; the page says what starts a run, because a
64 // run is the page's idea and not the game's.
65 expect(BINDINGS).toEqual({ ...KEYS, Enter: "start" });
66 expect(KEYS.Enter).toBeUndefined();
67 expect(TICK).toBe(1 / 60);
68 });
69
70 test("it boots, asks for a frame, and draws the room and the readout", () => {
71 const { p, undo } = booted();
72 try {
73 expect(p.frames.length).toBe(1);
74 const first = drawn(p, 16);
75 // The canvas was sized to its box at this page's 1× ratio, and drawn on.
76 expect([p.scene.width, p.scene.height]).toEqual([800, 450]);
77 expect(first.texts).toContain("score 0");
78 expect(first.texts).toContain("0:30");
79 expect(first.texts).toContain("press Enter, or Start, for thirty seconds");
80 expect(p.by("fps").textContent).toContain("ms");
81 // The room, the plate, the aim and the countdown are filled rectangles:
82 // everything on this canvas came out of the engine's overlay.
83 expect(first.calls.filter((c) => c[0] === "fillRect").length).toBeGreaterThan(4);
84 expect(aim(first.calls).length).toBeGreaterThan(0);
85 // A frame was asked for before this one ran, so the loop keeps going.
86 expect(p.frames.length).toBe(1);
87 } finally {
88 undo();
89 }
90 });
91
92 test("the second canvas the shared stage renders is never touched", () => {
93 // One canvas, one context, no WebGL: the smallest a game here can be.
94 const { p, undo } = booted();
95 try {
96 p.step(16);
97 expect(p.hud.paint?.calls).toEqual([]);
98 expect(p.hud.gl).toBeNull();
99 expect(p.scene.locks).toBe(0);
100 } finally {
101 undo();
102 }
103 });
104
105 test("Enter starts a run: the note gets out of the way and the clock counts down", () => {
106 const { p, undo } = booted();
107 try {
108 p.step(16);
109 expect(p.by("note").hidden).toBe(false);
110 p.window.fire("keydown", { code: "Enter" });
111 const started = drawn(p, 16);
112 expect(p.by("note").hidden).toBe(true);
113 expect(started.texts).toContain("arrows turn · WASD walks · Space fires");
114 // A second and a half of frames is a second and a half off the clock,
115 // however the frames were cut.
116 for (let i = 0; i < 14; i++) p.step(100);
117 expect(drawn(p, 100).texts).toContain("0:29");
118 } finally {
119 undo();
120 }
121 });
122
123 test("a held key reaches the game: turning moves the aim, walking moves the player", () => {
124 const { p, undo } = booted();
125 try {
126 p.window.fire("keydown", { code: "Enter" });
127 const still = drawn(p, 16);
128 p.window.fire("keydown", { code: "ArrowLeft" });
129 for (let i = 0; i < 4; i++) p.step(100);
130 const turned = drawn(p, 100);
131 expect(aim(turned.calls)).not.toBe(aim(still.calls));
132 // Letting go stops the turn: the next two frames aim at the same place.
133 p.window.fire("keyup", { code: "ArrowLeft" });
134 p.step(100);
135 const held = drawn(p, 100);
136 expect(aim(drawn(p, 100).calls)).toBe(aim(held.calls));
137
138 // Walking north is up the plan, because the room starts facing north.
139 const from = marker(held.calls);
140 p.window.fire("keydown", { code: "KeyW" });
141 for (let i = 0; i < 4; i++) p.step(100);
142 expect(marker(drawn(p, 100).calls)[1]).toBeLessThan(from[1]);
143 } finally {
144 undo();
145 }
146 });
147
148 test("a key the game does not bind does nothing at all", () => {
149 const { p, undo } = booted();
150 try {
151 p.window.fire("keydown", { code: "Enter" });
152 p.step(100);
153 const before = drawn(p, 100);
154 p.window.fire("keydown", { code: "KeyQ" });
155 expect(aim(drawn(p, 100).calls)).toBe(aim(before.calls));
156 expect(marker(drawn(p, 100).calls)).toEqual(marker(before.calls));
157 } finally {
158 undo();
159 }
160 });
161
162 test("the on-screen Start button is the same action as the key", () => {
163 const { p, undo } = booted();
164 try {
165 p.step(16);
166 // The last button the page renders is Start, in the note's own row.
167 p.buttons[p.buttons.length - 1]?.fire("pointerdown");
168 expect(drawn(p, 16).texts).toContain("arrows turn · WASD walks · Space fires");
169 expect(p.by("note").hidden).toBe(true);
170 } finally {
171 undo();
172 }
173 });
174
175 test("the clock ends the run and the note says what it was worth", () => {
176 const { p, undo } = booted();
177 try {
178 p.window.fire("keydown", { code: "Enter" });
179 p.step(16); // the run starts on the frame that reads the key, not on the key
180 p.window.fire("keyup", { code: "Enter" });
181 // 250 ms is the longest frame the loop believes, and eight steps of it are
182 // all it will run: a whole run is a few hundred of those.
183 for (let i = 0; i < 240; i++) p.step(250);
184 const over = drawn(p, 250);
185 expect(over.texts).toContain("0:00");
186 expect(over.texts).toContain("time — press Enter for another thirty seconds");
187 expect(p.by("note").hidden).toBe(false);
188 expect(p.by("note").innerHTML).toContain("Time");
189 expect(p.by("note").innerHTML).toContain(`in ${RUN} seconds`);
190 // And Enter starts another thirty seconds.
191 p.window.fire("keydown", { code: "Enter" });
192 expect(drawn(p, 16).texts).toContain("0:30");
193 expect(p.by("note").hidden).toBe(true);
194 } finally {
195 undo();
196 }
197 });
198
199 test("the touch pads are shown to a coarse pointer and to nobody else", () => {
200 const mouse = booted();
201 try {
202 expect(mouse.p.by("touch").hidden).toBe(true);
203 } finally {
204 mouse.undo();
205 }
206 const phone = booted({ coarse: true });
207 try {
208 expect(phone.p.by("touch").hidden).toBe(false);
209 } finally {
210 phone.undo();
211 }
212 });
213
214 test("a canvas with no context is a sentence on the page, not an exception", () => {
215 const { p, undo } = booted({ has2d: false });
216 try {
217 expect(p.by("note").innerHTML).toContain("No 2D canvas");
218 expect(p.by("note").hidden).toBe(false);
219 expect(p.frames.length).toBe(0);
220 expect(p.window.listening.size).toBe(0);
221 } finally {
222 undo();
223 }
224 });
src/example/bundle.test.ts added +94
@@ -0,0 +1,94 @@
1 /**
2 * The committed bundle, booted. `boot.test.ts` proves this game's *sources* run
3 * against the engine's headless page; this proves the minified file in `static/`
4 * does, which is the file a browser actually downloads. A bundling mistake — a
5 * mangled name the entry needed, a tree shake that took the boot line with it —
6 * fails here rather than in the preview.
7 *
8 * It also reads the bytes for what is *not* in them, which is this game's whole
9 * claim: it is 2D, so no renderer, no atlas, no sound device and no policy
10 * network came along behind the engine's door. A door is only worth having if
11 * importing it costs what you use, and this file is where that is measured.
12 *
13 * Finding the artifact and importing it afresh is `scripts/bundle.test-helper.ts`,
14 * shared with both other games — the same file, the same trick, one copy of it.
15 */
16
17 import { afterAll, expect, test } from "bun:test";
18 import { readFileSync } from "node:fs";
19 import { bundlePath, discardBundleCopies, importBundle } from "../../scripts/bundle.test-helper.ts";
20 import { page, type ExamplePage } from "./stage.test-helper.ts";
21
22 afterAll(discardBundleCopies);
23
24 /** Whether this process had a `document` before any of this ran. It does not. */
25 const HAD_DOCUMENT = "document" in globalThis;
26
27 /**
28 * Install a stub page, import the committed bundle into it — the entry boots
29 * itself on the way in — and hand back the page with the undo.
30 */
31 async function booted(options: { has2d?: boolean } = {}): Promise<{ p: ExamplePage; undo: () => void }> {
32 const p = page(options);
33 const undo = p.install();
34 try {
35 await importBundle("example");
36 } catch (error) {
37 undo();
38 throw error;
39 }
40 return { p, undo };
41 }
42
43 const texts = (p: ExamplePage): string[] => p.scene.paint?.texts ?? [];
44
45 test("the committed bundle boots the page and draws what the loop produced", async () => {
46 const { p, undo } = await booted();
47 try {
48 // Importing it was enough: it found the page and asked for a frame.
49 expect(p.frames.length).toBe(1);
50 p.step(16);
51 expect([p.scene.width, p.scene.height]).toEqual([800, 450]);
52 // Drawn text, so minification has nothing to hide behind.
53 expect(texts(p)).toContain("score 0");
54 expect(texts(p)).toContain("0:30");
55 expect(p.by("fps").textContent).toContain("ms");
56 // And the second canvas is still untouched by the file a browser runs.
57 expect(p.hud.paint?.calls).toEqual([]);
58
59 // Enter runs the guide's game: the clock moves and the help line changes.
60 p.window.fire("keydown", { code: "Enter" });
61 p.step(16);
62 expect(p.by("note").hidden).toBe(true);
63 expect(texts(p)).toContain("arrows turn · WASD walks · Space fires");
64 } finally {
65 undo();
66 }
67 });
68
69 test("a bundled canvas with no context is a sentence on the page, not an exception", async () => {
70 const { p, undo } = await booted({ has2d: false });
71 try {
72 expect(p.by("note").innerHTML).toContain("No 2D canvas");
73 expect(p.frames.length).toBe(0);
74 expect(p.window.listening.size).toBe(0);
75 } finally {
76 undo();
77 }
78 });
79
80 test("the example bundles none of the engine it does not use", () => {
81 // A tool set ships whole to a game that uses any of it, so the way to say
82 // this game uses none of these is to read the file. Each name below is one
83 // the minifier cannot rename and only one module of the engine has.
84 const code = readFileSync(bundlePath("example"), "utf8");
85 expect(code.length).toBeGreaterThan(1000); // the reader is alive
86 expect(code).toContain("getContext"); // and it is a game on a canvas
87 for (const name of ["webgl2", "AudioContext", "Math.tanh", "atob", "btoa", "createImageBitmap"])
88 expect(code).not.toContain(name);
89 });
90
91 test("the stub page leaves nothing of itself behind", () => {
92 // Every boot above undid its install, so no other test inherits a document.
93 expect("document" in globalThis).toBe(HAD_DOCUMENT);
94 });
src/example/main.ts added +112
@@ -0,0 +1,112 @@
1 /**
2 * The example's entry: what a game on this package costs once the game itself
3 * is written. The game is not here — it is `src/engine/example.ts`, the guide's
4 * own code, which `guide.test.ts` and `example.test.ts` already run — and this
5 * is the wiring a browser needs around it: find the canvas, hand the engine's
6 * controls the game's bindings table, run the engine's fixed loop, call the
7 * game's `step` with the actions it collected, and draw through `./plan.ts`.
8 * No rule of the game is repeated here and no other game is imported.
9 *
10 * A run — thirty seconds, and a clock counting them down — is the page's idea
11 * and not the game's, so the table below is the game's plus the one key that
12 * starts one: the game says what `KeyW` means, the page says what Enter means.
13 *
14 * 2D and nothing else: one canvas, one context, no WebGL, no atlas, no sound
15 * and no persistence. `bundle.test.ts` reads the committed file to say so.
16 */
17
18 import { KEYS, TICK, createWorld, step, type World } from "../engine/example.ts";
19 import { attachButtons, createControls, createLoop, type Paint } from "../engine/index.ts";
20 import { drawPlan, type Phase } from "./plan.ts";
21
22 /** The game's own table, and the one key the page adds to start a run with. */
23 export const BINDINGS: Record<string, string> = { ...KEYS, Enter: "start" };
24
25 /** Seconds a run lasts, and the actions a button press releases by itself. */
26 export const RUN = 30;
27 const TAPS = ["fire", "start"];
28
29 /** A seed for the next run: the clock, which nobody has to remember or type. */
30 const freshSeed = (): number => (Date.now() & 0xffff) >>> 0;
31
32 const $ = <T extends HTMLElement>(id: string): T => document.getElementById(id) as T;
33
34 /**
35 * Wire the page up and start the loop. Exported so `boot.test.ts` can run the
36 * whole entry against the engine's headless page; in a browser the line at the
37 * bottom of this file is what calls it.
38 */
39 export function boot(): void {
40 const scene = $<HTMLCanvasElement>("scene");
41 const note = $("note");
42
43 // A canvas that gives no context is a sentence on the page, never a throw:
44 // the text, the links and the terminal line on this page all still work.
45 const paint = scene.getContext("2d") as Paint | null;
46 if (paint === null) {
47 note.innerHTML = "<h2>No 2D canvas</h2><p>This browser gave the page no drawing context, so the room" +
48 " cannot be drawn. The rest of the page still works.</p>";
49 return;
50 }
51
52 const controls = createControls({ surface: scene, window, document }, { keys: BINDINGS, requireLock: false });
53 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: TAPS });
54 $("touch").hidden = !matchMedia("(pointer: coarse)").matches;
55 const loop = createLoop({ tick: TICK }, {
56 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
57 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
58 now: () => performance.now(),
59 });
60
61 let world: World = createWorld(freshSeed());
62 let phase: Phase = "ready";
63 let clock = RUN;
64 // Start is an edge, not a hold: a leant-on Enter is one run, not sixty.
65 let starting = false;
66 let frameMs = 16.7;
67
68 /** Match the canvas's backing store to its box, at most twice the CSS pixels. */
69 const size = (): [number, number] => {
70 const dpr = Math.min(window.devicePixelRatio || 1, 2);
71 const width = Math.max(1, Math.round(scene.clientWidth * dpr));
72 const height = Math.max(1, Math.round(scene.clientHeight * dpr));
73 if (scene.width !== width) scene.width = width;
74 if (scene.height !== height) scene.height = height;
75 return [width, height];
76 };
77
78 loop.start((elapsed) => {
79 frameMs += (elapsed * 1000 - frameMs) * 0.05;
80 const steps = loop.advance(elapsed);
81
82 const asked = controls.held.has("start");
83 if (asked && !starting) {
84 world = createWorld(freshSeed());
85 phase = "live";
86 clock = RUN;
87 note.hidden = true;
88 }
89 starting = asked;
90
91 // The game moves in whole ticks, so a slow frame and a fast one play the
92 // same thirty seconds. Between runs it does not move at all.
93 if (phase === "live") {
94 for (let i = 0; i < steps; i++) world = step(world, TICK, controls.held);
95 clock -= steps * TICK;
96 if (clock <= 0) {
97 clock = 0;
98 phase = "over";
99 note.innerHTML = `<h2>Time</h2><p>${world.score} plate${world.score === 1 ? "" : "s"} in ${RUN} seconds.` +
100 ` Press <kbd>Enter</kbd>, or Start, to go again.</p>`;
101 note.hidden = false;
102 }
103 }
104
105 const [width, height] = size();
106 drawPlan(paint, width, height, { world, phase, clock, fraction: clock / RUN });
107 $("fps").textContent = `${(1000 / Math.max(1, frameMs)).toFixed(0)} fps · ${frameMs.toFixed(1)} ms`;
108 });
109 }
110
111 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
112 else boot();
src/example/plan.test.ts added +105
@@ -0,0 +1,105 @@
1 /**
2 * The plan, drawn into a recorder. There is no canvas here and no page: the
3 * engine's `FakePaint` is a `Paint`, so a frame is a list of calls a test can
4 * read — where the room went, where the plate went, which way the aim points
5 * and what the readout said.
6 */
7
8 import { expect, test } from "bun:test";
9 import { ROOM, createWorld } from "../engine/example.ts";
10 import { fakePage, type FakePaint } from "../engine/index.ts";
11 import { drawPlan, type Frame } from "./plan.ts";
12
13 const WIDTH = 800, HEIGHT = 450;
14
15 /** A recorder, and one frame drawn into it. */
16 function drew(over: Partial<Frame> = {}): FakePaint {
17 const paint = fakePage().surface.paint as FakePaint;
18 const world = createWorld(7);
19 drawPlan(paint, WIDTH, HEIGHT, { world, phase: "ready", clock: 30, fraction: 1, ...over });
20 return paint;
21 }
22
23 const rects = (paint: FakePaint): number[][] =>
24 paint.calls.filter((c) => c[0] === "fillRect").map((c) => c.slice(1).map(Number));
25
26 /** The aim dots: the only 2×2 rectangles on the canvas, oldest first. */
27 const dots = (paint: FakePaint): number[][] => rects(paint).filter((r) => r[2] === 2 && r[3] === 2);
28
29 test("a frame is one background, one room, one plate, the aim and the readout", () => {
30 const paint = drew();
31 const all = rects(paint);
32 // The first rectangle covers the canvas: there is no clearRect in a frame,
33 // because the overlay's panel is the only thing this game draws with.
34 expect(all[0]).toEqual([0, 0, WIDTH, HEIGHT]);
35 expect(paint.calls.some((c) => c[0] === "clearRect")).toBe(false);
36 // The room is square on the canvas, and fits inside it.
37 const room = all.find((r) => r[2] === r[3] && r[2] > 100) as number[];
38 expect(room[2]).toBeGreaterThan(100);
39 expect(room[0]).toBeGreaterThanOrEqual(0);
40 expect((room[0] as number) + (room[2] as number)).toBeLessThanOrEqual(WIDTH);
41 expect((room[1] as number) + (room[3] as number)).toBeLessThanOrEqual(HEIGHT);
42 // The readout, and the one line of help.
43 expect(paint.texts).toEqual(["score 0", "0:30", "press Enter, or Start, for thirty seconds"]);
44 // The player is a crosshair: four arms, one path, one stroke.
45 expect(paint.calls.filter((c) => c[0] === "moveTo").length).toBe(4);
46 expect(paint.calls.filter((c) => c[0] === "stroke").length).toBe(1);
47 });
48
49 test("the aim walks out of the eye and stops at the wall", () => {
50 const paint = drew();
51 const walked = dots(paint);
52 expect(walked.length).toBeGreaterThan(2);
53 // The world starts facing north, which is up the plan: every dot is higher
54 // than the one before it, and none of them leaves the room.
55 for (let i = 1; i < walked.length; i++) expect(walked[i]?.[1]).toBeLessThan(walked[i - 1]?.[1] as number);
56 const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100) as number[];
57 for (const dot of walked) expect(dot[1]).toBeGreaterThanOrEqual(room[1] as number);
58 });
59
60 test("turning turns the aim and nothing else", () => {
61 const world = createWorld(7);
62 const north = drew({ world });
63 const east = drew({ world: { ...world, yaw: 0 } });
64 expect(dots(east)).not.toEqual(dots(north));
65 // The room and the plate did not move: only the dots did.
66 expect(rects(north).filter((r) => r[2] !== 2)).toEqual(rects(east).filter((r) => r[2] !== 2));
67 });
68
69 test("the plate is drawn where the game says it is, and is never thinner than a line", () => {
70 const world = createWorld(7);
71 const paint = drew({ world });
72 const plate = rects(paint).find((r) => r[2] > 2 && r[2] < 100) as number[];
73 expect(plate[3]).toBeGreaterThanOrEqual(2);
74 // It is against the far wall, so it is drawn above the middle of the plan.
75 const room = rects(paint).find((r) => r[2] === r[3] && r[2] > 100) as number[];
76 expect(plate[1]).toBeLessThan((room[1] as number) + (room[3] as number) / 2);
77 expect(world.target.max[1]).toBeLessThanOrEqual(ROOM.max[1]);
78 });
79
80 test("the clock reads the overlay's m:ss, and the last seconds are said differently", () => {
81 expect(drew({ clock: 30 }).texts).toContain("0:30");
82 expect(drew({ clock: 9.2 }).texts).toContain("0:10");
83 expect(drew({ clock: 0 }).texts).toContain("0:00");
84 // The countdown bar is the fraction of the run that is left.
85 const full = rects(drew({ clock: 30, fraction: 1 }));
86 const half = rects(drew({ clock: 15, fraction: 0.5 }));
87 const bar = (r: number[][]): number[] => r.filter((x) => x[3] === 3)[0] as number[];
88 expect(bar(half)[2]).toBeCloseTo((bar(full)[2] as number) / 2, 6);
89 });
90
91 test("each phase says its own line and no other", () => {
92 expect(drew({ phase: "ready" }).texts[2]).toContain("press Enter");
93 expect(drew({ phase: "live" }).texts[2]).toContain("arrows turn");
94 expect(drew({ phase: "over" }).texts[2]).toContain("time");
95 });
96
97 test("a narrow phone is the same frame, laid out inside it", () => {
98 const paint = fakePage().surface.paint as FakePaint;
99 drawPlan(paint, 360, 270, { world: createWorld(7), phase: "live", clock: 12, fraction: 0.4 });
100 for (const r of rects(paint)) {
101 expect(r[0]).toBeGreaterThanOrEqual(0);
102 expect((r[0] as number) + (r[2] as number)).toBeLessThanOrEqual(360 + 1);
103 expect((r[1] as number) + (r[3] as number)).toBeLessThanOrEqual(270 + 1);
104 }
105 });
src/example/plan.ts added +86
@@ -0,0 +1,86 @@
1 /**
2 * The guide's room, from above. The game in `src/engine/example.ts` has four
3 * facts — where you stand, which way you face, where the plate is and what you
4 * have hit — and this draws all four as a plan, through the engine's `overlay`
5 * and nothing else: the floor, the plate and the aim are filled rectangles, the
6 * player is a crosshair, the countdown is a bar and the clock is the overlay's
7 * own `m:ss`. No WebGL, no atlas, no mesh and no sound.
8 *
9 * It takes a `Paint` and a size, so a test draws a frame into a recorder and
10 * reads the calls back; nothing is remembered between frames.
11 */
12
13 import { ROOM, type World } from "../engine/example.ts";
14 import { angleVectors, overlay, type Box, type Paint } from "../engine/index.ts";
15
16 /** Where a run is: waiting to start, running, or finished with a score. */
17 export type Phase = "ready" | "live" | "over";
18
19 /** Everything one frame of this game looks like, and all of it plain data. */
20 export type Frame = {
21 world: World;
22 phase: Phase;
23 /** Seconds left on the run's clock. */
24 clock: number;
25 /** How much of the run is left, 0..1 — the bar under the clock. */
26 fraction: number;
27 };
28
29 /** The shell's palette, so the canvas and the page around it are one thing. */
30 const BACK = "#0b0d11", FLOOR = "#141821", PLATE = "#e2b04a";
31 const AIM = "#6fa8e8", TEXT = "#e7eaf0", DIM = "#9aa4b4", WARN = "#e2564a";
32
33 /** The last seconds a clock is read out in the colour of one running out, and
34 * the aim: how many dots down the view, and how far apart in metres. */
35 const HURRY = 5, AIM_DOTS = 16, AIM_STEP = 0.4;
36
37 /** The one line under the plan, per phase. It is the whole of this game's help. */
38 const SAY: Record<Phase, string> = {
39 ready: "press Enter, or Start, for thirty seconds",
40 live: "arrows turn · WASD walks · Space fires",
41 over: "time — press Enter for another thirty seconds",
42 };
43
44 /** One frame, drawn. The overlay is built here because the size is this call's. */
45 export function drawPlan(paint: Paint, width: number, height: number, frame: Frame): void {
46 const o = overlay(paint, width, height);
47 const { world, clock } = frame;
48 const late = clock <= HURRY;
49 o.panel(0, 0, width, height, BACK);
50
51 // The readout: the score, the clock, and how much of the run is left.
52 const line = o.pad + Math.round(30 * o.scale);
53 o.text(`score ${world.score}`, o.pad, line, { font: o.font(30), fill: TEXT });
54 o.text(o.clock(clock), width - o.pad, line, { align: "right", font: o.font(30), fill: late ? WARN : TEXT });
55 o.bar(o.pad, line + Math.round(8 * o.scale), width - o.pad * 2, Math.max(2, Math.round(4 * o.scale)),
56 frame.fraction, late ? WARN : AIM);
57
58 // The plan itself: the room is square, so the biggest square that fits under
59 // the readout is the scale everything below is drawn at.
60 const side = Math.max(1, Math.min(width - o.pad * 2, height - line - o.pad * 3));
61 const metres = side / (ROOM.max[0] - ROOM.min[0]);
62 const cx = width / 2;
63 const cy = line + o.pad * 2 + side / 2;
64 const sx = (x: number): number => cx + x * metres;
65 const sy = (y: number): number => cy - y * metres;
66 const box = ({ min, max }: Box, fill: string): void =>
67 o.panel(sx(min[0]), sy(max[1]), Math.max(2, (max[0] - min[0]) * metres),
68 Math.max(2, (max[1] - min[1]) * metres), fill);
69
70 box(ROOM, FLOOR);
71 box(world.target, PLATE);
72
73 // Where the next shot goes: the engine's own forward vector, walked out in
74 // dots until it leaves the room. Turning is the one thing you must be able
75 // to see, and on a plan this is what turning looks like.
76 const dot = Math.max(2, Math.round(3 * o.scale));
77 const { forward } = angleVectors(world.yaw, 0);
78 for (let i = 1; i <= AIM_DOTS; i++) {
79 const x = world.pos[0] + forward[0] * i * AIM_STEP;
80 const y = world.pos[1] + forward[1] * i * AIM_STEP;
81 if (x < ROOM.min[0] || x > ROOM.max[0] || y < ROOM.min[1] || y > ROOM.max[1]) break;
82 o.panel(sx(x) - dot / 2, sy(y) - dot / 2, dot, dot, AIM);
83 }
84 o.crosshair(sx(world.pos[0]), sy(world.pos[1]), 0, Math.round(8 * o.scale), TEXT);
85 o.text(SAY[frame.phase], width / 2, height - o.pad, { align: "center", font: o.font(18), fill: DIM });
86 }
src/example/stage.test-helper.ts added +83
@@ -0,0 +1,83 @@
1 /**
2 * The example's half of a stub page, and it is a short half: the page itself is
3 * the engine's — `fakePage` behind the door hands back the document, the
4 * window, the elements, the 2D recorder and the clock. What is genuinely this
5 * game's is the list of ids `src/pages/example.ts` renders, the buttons it
6 * renders, and the line that installs all of it as the globals `main.ts` reads.
7 *
8 * There is no GL recorder and no audio counter here, and that is the point: the
9 * example opens neither, so a page that offers neither is a page it still boots
10 * on. `AudioContext` is deliberately not installed — a boot that reached for
11 * one would throw here rather than pass.
12 *
13 * It ships in no bundle: only `boot.test.ts` and `bundle.test.ts` import it.
14 */
15
16 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakeWindow } from "../engine/index.ts";
17
18 /** Every id `src/pages/example.ts` puts on the page that the entry looks up. */
19 export const EXAMPLE_IDS = ["scene", "hud", "fps", "touch", "note"];
20
21 /** The `[data-action]` buttons the page renders: the touch pads, and Start. */
22 export const EXAMPLE_ACTIONS = ["turnLeft", "turnRight", "forward", "fire", "start"];
23
24 /** The globals the last lines of `main.ts` and the engine's controls name. */
25 const GLOBALS = ["document", "window", "requestAnimationFrame", "cancelAnimationFrame", "matchMedia", "performance"];
26
27 export type ExamplePage = {
28 document: FakeDocument;
29 window: FakeWindow;
30 scene: FakeCanvas;
31 /** The second canvas the shared stage renders. This game never touches it. */
32 hud: FakeCanvas;
33 by(id: string): FakeElement;
34 buttons: FakeElement[];
35 /** The frames asked for but not yet run, oldest first. */
36 frames: ((now: number) => void)[];
37 /** Run the next frame, `ms` after the last one. */
38 step(ms: number): void;
39 /** Install this page as the globals `main.ts` reads, and hand back the undo. */
40 install(): () => void;
41 };
42
43 /** The whole page this game's entry expects. `has2d: false` gives no context. */
44 export function page(options: { has2d?: boolean; coarse?: boolean } = {}): ExamplePage {
45 const fake = fakePage({
46 ids: EXAMPLE_IDS,
47 canvases: ["scene", "hud"],
48 actions: EXAMPLE_ACTIONS,
49 paint: options.has2d ?? true,
50 coarse: options.coarse,
51 });
52 const [scene, hud] = fake.canvases as [FakeCanvas, FakeCanvas];
53
54 return {
55 document: fake.document,
56 window: fake.window,
57 scene,
58 hud,
59 buttons: fake.buttons,
60 frames: fake.frames,
61 by: fake.by,
62 step: fake.step,
63 install() {
64 // The undo is a snapshot of the global object's own enumerable properties,
65 // which is what the other games' helpers take: a global that is not one —
66 // `performance` is not — is deleted rather than restored, because putting
67 // a native global back with `defineProperty` costs every lookup after it.
68 const globals = globalThis as Record<string, unknown>;
69 const before = { ...globals };
70 globals.document = fake.document;
71 globals.window = fake.window;
72 globals.requestAnimationFrame = fake.window.requestAnimationFrame;
73 globals.cancelAnimationFrame = fake.window.cancelAnimationFrame;
74 globals.matchMedia = fake.window.matchMedia;
75 globals.performance = fake.window.performance;
76 return () => {
77 for (const key of GLOBALS)
78 if (key in before) globals[key] = before[key];
79 else delete globals[key];
80 };
81 },
82 };
83 }
src/games.ts +16
@@ -87,6 +87,13 @@ export type Game = {
8787 * the app's bundle and in neither line of the range's. Measured 2026-09-18 after
8888 * #106: app 71,646 raw / 33,273 gzipped, range 27,268 raw / 11,674 gzipped.
8989 * Both fit, so neither ceiling moved.
90 * #80 added a third entry, `example`, and its ceiling is the first number in
91 * this file nobody had to argue about: the guide's game with a page around it
92 * builds to 8,754 raw / 4,229 gzipped, so its ceiling is 9,700 / 4,700, today's
93 * size plus about 10% like the other two. It is an eighth of the shooter
94 * because it is 2D: no renderer, no atlas, no sound and no net ship in it, and
95 * `src/example/bundle.test.ts` reads the file to say so. Neither other bundle
96 * moved a byte for it.
9097 */
9198 export const GAMES: readonly Game[] = [
9299 {
@@ -107,4 +114,13 @@ export const GAMES: readonly Game[] = [
107114 blurb:
108115 "thirty seconds on the clock, pop-up plates down a lane, a shot traced in three dimensions and a score — every line of it out of the engine and none of it out of the shooter.",
109116 },
117 {
118 name: "example",
119 href: "/example",
120 label: "example",
121 entry: "src/example/main.ts",
122 ceiling: { raw: 9_700, gzip: 4_700 },
123 blurb:
124 "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else — the price tag on a third game.",
125 },
110126 ];
src/independence.test.ts +13 −1
@@ -91,13 +91,18 @@ export function importsAnotherGame(file: string, specifier: string): string | nu
9191 return `${file} imports "${specifier}"; a game may not import another game`;
9292 }
9393
94 test("both games have the sources this test is about, and the app has none", () => {
94 test("every game has the sources this test is about, and the app has none", () => {
9595 const files = outsideSources();
9696 const of = (game: string): string[] => files.filter((f) => gameOf(f) === game);
9797 expect(of("app")).toContain("src/client/world.ts");
9898 expect(of("app")).toContain("src/rl/arena.ts");
9999 expect(of("range")).toContain("src/range/targets.ts");
100100 expect(of("range")).toContain("src/range/main.ts");
101 // The third game was covered by its registry entry and nothing else: this
102 // file gained no rule for it, which is what a third game was meant to prove.
103 expect(of("example")).toContain("src/example/main.ts");
104 expect(of("example")).toContain("src/example/plan.ts");
105 expect(GAMES.map((game) => game.name)).toContain("example");
101106 // The app is not a game: it is what the games are served by.
102107 expect(gameOf("src/server.ts")).toBeNull();
103108 expect(gameOf("src/pages/shell.ts")).toBeNull();
@@ -132,6 +137,13 @@ test("the rule is refused both ways round, and names who did it", () => {
132137 `src/rl/arena.ts imports "../range/targets.ts"; a game may not import another game`,
133138 );
134139 expect(importsAnotherGame("src/range/pin.ts", "../rl/arena.ts")).toMatch(/another game/);
140 // And the third game is held to it from the moment it was written down.
141 expect(importsAnotherGame("src/example/main.ts", "../client/world.ts")).toMatch(/another game/);
142 expect(importsAnotherGame("src/example/plan.ts", "../range/targets.ts")).toMatch(/another game/);
143 expect(importsAnotherGame("src/range/main.ts", "../example/plan.ts")).toMatch(/another game/);
144 // The guide's game is the engine's, not a game's, so sharing it is allowed:
145 // `src/engine/boundary.test.ts` is where that one path is decided.
146 expect(importsAnotherGame("src/example/main.ts", "../engine/example.ts")).toBeNull();
135147 });
136148
137149 // A title that ends on the keyword, with the closing quote right behind it,
src/pages/example.ts added +76
@@ -0,0 +1,76 @@
1 /**
2 * The example's page: the guide's game given a document, and a page about what
3 * that cost. The shell in `./shell.ts` renders everything that is not this
4 * game's — the doctype, the style, the header, the stage and the footer — so
5 * what is left here is one overlay, four touch buttons and the two paragraphs
6 * a visitor is owed: what they are looking at, and what it took to put it
7 * there.
8 *
9 * It is deliberately one screen. This game is a demonstration and the honest
10 * thing to demonstrate is the price tag, not a third shooter.
11 */
12
13 import { navFor, othersHtml } from "./games.ts";
14 import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
15
16 /** What the shell needs that this page cannot know: the environment it runs in. */
17 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh">;
18
19 const NOTE = `
20 <h2>The smallest game this engine can run</h2>
21 <p>One room seen from above, one plate on the far wall, thirty seconds and a score. The arrow
22 keys turn you, <kbd>WASD</kbd> walks, <kbd>Space</kbd> fires one shot a press, and the blue
23 dots are where that shot goes.</p>
24 <div class="row"><button type="button" class="primary" data-action="start">Start</button></div>
25 `;
26
27 export function examplePage(options: PageOptions): string {
28 return page({
29 ...options,
30 title: "example — what a game costs on this engine",
31 description:
32 "The guide's own game, served: one room, one plate, thirty seconds and a score, drawn in 2D through the engine's overlay and nothing else.",
33 heading: "example",
34 nav: navFor("/example"),
35 terminalLead: "The shooter's leaderboard in a terminal:",
36 footer: "no database, no cookie: this page keeps nothing",
37 stageHtml: stage({
38 touch: [
39 [
40 { action: "turnLeft", label: "turn ←" },
41 { action: "turnRight", label: "turn →" },
42 ],
43 [
44 { action: "forward", label: "walk" },
45 { action: "fire", label: "fire" },
46 ],
47 ],
48 overlays: [{ id: "note", html: NOTE }],
49 }),
50 sectionsHtml: ` <section>
51 <h2>What this cost</h2>
52 <p>The game on this page is not on this page. It is <code>src/engine/example.ts</code>, the
53 seventy-nine lines the engine's guide is written out of — a room, a plate, a seeded spawn, a
54 fixed step and one shot per press — and it was already there, already tested, before this
55 route existed. What a third game cost was the wiring around it:
56 <code>src/example/main.ts</code> finds the canvas, hands the engine's controls the game's own
57 bindings table, runs the engine's fixed loop and calls the game's <code>step</code>;
58 <code>src/example/plan.ts</code> draws the result. That is two files and about two hundred
59 lines, one registry entry in <code>src/games.ts</code>, this page, and one line in the map
60 from a game's name to its page. No branch was added to the server, and nothing in the header,
61 the 404 or the other two pages was edited: they read the registry, so this game appeared in
62 all of them the moment it was written down.</p>
63 <p>What it did not have to write is the longer list: no loop, no fixed step, no bindings table
64 reader, no touch buttons, no pointer handling, no ray against a box, no seeded random, no
65 vector maths, no panel, no bar, no clock, no text scaling — all of that is the package, and
66 this game reaches for it through one door, <code>src/engine/index.ts</code>. It is 2D on one
67 canvas: no WebGL, no atlas, no mesh, no sound and no database, and the door costs what you
68 use — the script this page just downloaded is 8,754 bytes, 4,229 of them over the wire,
69 against the shooter's 71,646. Every rectangle, the crosshair, the countdown bar and the
70 <code>m:ss</code> clock come out of the engine's <code>overlay</code>, and nothing else draws
71 here.</p>
72 <p>Nothing on this page is stored: no endpoint, no table and no cookie. The leaderboard, the
73 database and the terminal belong to the games next door: ${othersHtml("/example")}</p>
74 </section>`,
75 });
76 }
src/pages/shell.test.ts +11 −3
@@ -126,9 +126,17 @@ test("a nav entry is text, wherever it enters", () => {
126126 });
127127
128128 test("the list of games lives once, and a page marks its own", () => {
129 expect(GAMES.map((g) => g.href)).toEqual(["/", "/range"]);
130 expect(navFor("/")).toEqual([{ href: "/", label: "cs", current: true }, { href: "/range", label: "range" }]);
131 expect(navFor("/range")).toEqual([{ href: "/", label: "cs" }, { href: "/range", label: "range", current: true }]);
129 expect(GAMES.map((g) => g.href)).toEqual(["/", "/range", "/example"]);
130 const others = [{ href: "/range", label: "range" }, { href: "/example", label: "example" }];
131 expect(navFor("/")).toEqual([{ href: "/", label: "cs", current: true }, ...others]);
132 expect(navFor("/range")).toEqual([
133 { href: "/", label: "cs" }, { href: "/range", label: "range", current: true },
134 { href: "/example", label: "example" },
135 ]);
136 expect(navFor("/example")).toEqual([
137 { href: "/", label: "cs" }, { href: "/range", label: "range" },
138 { href: "/example", label: "example", current: true },
139 ]);
132140 // A path that is no game marks nothing, and still lists them all.
133141 expect(navFor("/nope").filter((e) => e.current === true)).toEqual([]);
134142 expect(navFor("/nope")).toHaveLength(GAMES.length);
src/server.test.ts +29
@@ -8,6 +8,7 @@ import { escape } from "./pages/shell.ts";
88 import { parseResult } from "./result.ts";
99 import { sign } from "./who.ts";
1010 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
11 import { EXAMPLE_ACTIONS, EXAMPLE_IDS } from "./example/stage.test-helper.ts";
1112
1213 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1314 process.env.PORT = "0";
@@ -85,6 +86,34 @@ test("the range page carries every id and action its entry reaches for", async (
8586 for (const id of ["menu", "pause", "buy", "stats", "play"]) expect(body).not.toContain(`id="${id}"`);
8687 });
8788
89 test("serves the example on its own route, with its own page and its own bundle", async () => {
90 const response = await fetch(base + "/example");
91 expect(response.status).toBe(200);
92 const body = await response.text();
93 expect(body).toContain("<title>example — what a game costs on this engine</title>");
94 expect(body).toContain("<h1>example</h1>");
95 expect(body).toMatch(/<script type="module" src="\/static\/example\.[0-9a-f]+\.js">/);
96 expect(body.match(/<script/g)?.length).toBe(1);
97 // Neither other game's bundle, and neither other game's page.
98 expect(body).not.toMatch(/src="\/static\/(app|range)\./);
99 expect(body).not.toContain("Leaderboard");
100 expect(body).not.toContain("de_forge");
101 // No session for a page that stores nothing.
102 expect(response.headers.get("set-cookie")).toBeNull();
103 });
104
105 test("the example page carries every id and action its entry reaches for", async () => {
106 // src/example/main.ts finds these by getElementById and hands [data-action]
107 // to the engine's controls; nothing type-checks that, so the page is checked
108 // — against the same list the stub page in src/example/stage.test-helper.ts
109 // is built from, so the two cannot drift.
110 const body = await (await fetch(base + "/example")).text();
111 for (const id of EXAMPLE_IDS) expect(body).toContain(`id="${id}"`);
112 for (const action of EXAMPLE_ACTIONS) expect(body).toContain(`data-action="${action}"`);
113 // The other games' ids are not on this page: a shared shell is not a shared game.
114 for (const id of ["menu", "pause", "buy", "stats", "play", "look"]) expect(body).not.toContain(`id="${id}"`);
115 });
116
88117 test("the two pages link to each other", async () => {
89118 expect(await (await fetch(base + "/")).text()).toContain(`href="/range"`);
90119 expect(await (await fetch(base + "/range")).text()).toContain(`href="/"`);
src/server.ts +3
@@ -26,6 +26,7 @@ import { openDatabase, type Player, saveMatch } from "./db.ts";
2626 import { GAMES } from "./games.ts";
2727 import { createLimiter } from "./limit.ts";
2828 import { listHtml } from "./pages/games.ts";
29 import { examplePage } from "./pages/example.ts";
2930 import { gamePage, statsHtml } from "./pages/game.ts";
3031 import { rangePage } from "./pages/range.ts";
3132 import { cleanName, parseResult } from "./result.ts";
@@ -204,6 +205,8 @@ const RENDER: Record<string, Render> = {
204205 // The range: its own page and its own bundle, and no session — it stores
205206 // nothing, so it has nothing to name a browser for.
206207 range: (_request, script) => html(rangePage({ env, pull, commit, script, ssh })),
208 // The example: the guide's game with a page around it, and no session either.
209 example: (_request, script) => html(examplePage({ env, pull, commit, script, ssh })),
207210 };
208211
209212 // The read routes, resolved once at start-up: the path each game answers on,
static/example.95c913d802.js added +1

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs