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

forked from trevato/cs · proposes

The package says how to build a game on it: a guide beside the door, and no export left undocumented #55

Merged opened this

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

Merged

vesper/cs-engine:vesper/44-the-package-says-how-to-build-a-game-on → main 1 commit fork

  • Merged. vesper/44-the-package-says-how-to-build-a-game-on was merged into main as 7abe023.

Review

Sign in to review this change.
4 files changed +476 −1
README.md +5
@@ -145,6 +145,11 @@ from the angle it subtends, and you turn on the spot.
145145 it is written down there too: a `.test.ts` may import another `.test.ts`, which
146146 is how `src/range/independence.test.ts` reuses the source-reading helpers
147147 rather than keeping a second copy of them.
148 - `src/engine/GUIDE.md` — how to build a game on it: what a game supplies (an
149 entry, a host, a bindings table, a tick rate, its own recipes and its own
150 tuning), a paragraph and one true line of code per group behind the door, and
151 what the engine deliberately does not know. `guide.test.ts` holds it against
152 the door's list of names in both directions.
148153 - `src/client/` — the game, in modules named for one thing each: `map` (de_forge
149154 itself: its areas, its crates, its cell alphabet and its spawns and sites, laid
150155 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
src/engine/GUIDE.md added +288
@@ -0,0 +1,288 @@
1 # Building a game on this engine
2
3 `index.ts` is the *what*: the list of names the door offers, one line of comment
4 per group. This is the *how*: what a game has to bring before those names are
5 any use, in the order you would actually write it.
6
7 The engine is not a framework. It never calls your game — your game calls it,
8 from a frame it owns, with a page it opened and numbers it chose. Two games do
9 that here already: the shooter in `src/client/` and the range in `src/range/`.
10 Every line of code below is copied out of one of them.
11
12 **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's,
13 exported from `src/engine/index.ts`. A game's own name carries its module:
14 `binding.readIntent`, `targets.step`, `main.boot`. A path is a path. Every code
15 block names the file it was copied from on its first line.
16
17 `guide.test.ts` holds all three: every name the door exports is named here,
18 every bare engine name here is exported, and every line of every code block is
19 a line of the file above it. So when a group lands behind the door that this
20 page has no paragraph for — the physics of issue #3, one day — that test fails
21 until the paragraph is written. That is the point. A name arrives with a page or
22 it does not arrive.
23
24 ## What a game brings
25
26 ### 1. An entry
27
28 One directory, `src/<game>/`, and one file in it that is the frame:
29 `src/range/main.ts` is the whole of the range's wiring. It exports the function
30 that boots so a test can boot it against a stub page, and it boots itself on the
31 last two lines — the only place in the game a browser global is named.
32
33 ```ts
34 // src/range/main.ts
35 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
36 else boot();
37 ```
38
39 Three more lines and the game is reachable: an entry with a size ceiling in
40 `build.ENTRIES` in `scripts/build.ts`, a route in `src/server.ts`, and a page
41 that server-renders the markup the entry looks up by id (`src/pages/range.ts`).
42
43 ### 2. A host
44
45 The engine touches a browser in exactly two places, and both take it as a
46 parameter. Controls want a `Host` — `{ surface, window, document }`, where
47 `Surface` is anything with a width that listens, `Listens` is a pair of
48 `addEventListener`/`removeEventListener`, and `LockOwner` is anything with a
49 `pointerLockElement`. The loop wants `Frames`: a `requestAnimationFrame`, its
50 `cancelAnimationFrame`, and a `now()` in milliseconds.
51
52 ```ts
53 // src/client/main.ts
54 const controls = createControls(
55 { surface: scene, window: win, document: doc },
56 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
57 );
58 ```
59
60 Those two are the shooter's own `main.Host`: the real browser in production,
61 and plain objects in `src/client/stage.test-helper.ts` under test. The range
62 does the same with `src/range/dom.ts`. Nothing in the engine knows the
63 difference, because those types are the members it reads and nothing wider.
64
65 ### 3. A bindings table
66
67 The engine collects action *names* and knows none of them. Which key says which
68 name is the game's table, and it is data:
69
70 ```ts
71 // src/range/binding.ts
72 export const RANGE_KEYS: Record<string, string> = {
73 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
74 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
75 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
76 KeyF: "fire", KeyR: "reload", Enter: "start",
77 };
78 ```
79
80 Beside it go the mouse buttons, the on-screen buttons that are a tap rather than
81 a hold, and one function from the held set to whatever struct the game's
82 simulation takes — `binding.readIntent` in both games, returning two different
83 shapes. The engine hands you a `ReadonlySet<string>` and a `Stick`; what
84 "forward" means is yours.
85
86 ### 4. A tick rate
87
88 Pick the step the simulation runs at and never let a frame change it. The range
89 and the shooter disagree on purpose:
90
91 ```ts
92 // src/range/main.ts
93 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
94 const TICK = 1 / 50;
95 ```
96
97 `Loop.advance(elapsed)` banks the frame and returns whole steps; a slow frame
98 and a fast one then play the same run. `Loop.reset` drops the bank when a new
99 world starts.
100
101 ### 5. Its own recipes
102
103 There are no asset files in this repository and the engine ships none. It ships
104 three tools that turn a recipe into bytes — a box builder, an atlas painter and
105 a sound device — and the recipes belong to the game: `src/client/models.ts` is
106 the shooter's geometry, `src/client/materials.ts` its nine surfaces,
107 `src/client/audio.ts` its voice. A second game writes its own three, or, like
108 the range today, writes none and draws in 2D.
109
110 ### 6. Its own tuning
111
112 Every number that decides how the game feels is the game's: eye height, hull
113 size, gravity, how long a round lasts, what a plate is worth. The engine holds
114 no constants of its own beyond the loop's two safety clamps. Put them at the top
115 of a module, named, and a reader can tune the game without reading the engine.
116
117 ## What is behind the door
118
119 ### math
120
121 The arithmetic under everything, in `Vec3` (a plain `[x, y, z]`) and `Mat4` (a
122 `Float32Array`, column-major, ready for `uniformMatrix4fv`). Vectors: `v3`,
123 `add`, `sub`, `scale`, `mul`, `dot`, `cross`, `normalize`, `length`, `length2d`,
124 `dist`, `dist2d`. Scalars: `lerp`, `clamp`. Angles in degrees, because that is
125 what a view is stored in: `rad`, `deg`, `angleDelta` for the shortest way round,
126 and `angleVectors` for the forward/right/up of a yaw and a pitch. Matrices:
127 `mat4`, `multiply`, `perspective`, `viewMatrix`, `modelMatrix`. And `rng`, a
128 seeded generator — every random draw in a game that wants to be reproducible
129 comes from one of these, in a fixed order.
130
131 ```ts
132 // src/range/main.ts
133 if (intent.fire && !firing) shoot(run, EYE, angleVectors(yaw, pitch).forward);
134 ```
135
136 ### trace
137
138 Rays against axis-aligned boxes: `rayBox` for one box, `traceWorld` for a list
139 of them, `visible` for the yes/no of an unobstructed line, `segmentLength` for
140 how far the segment actually ran. A `Box` is `{ min, max, mat }` — the material
141 index is carried so the thing you hit knows what it is made of — and a `Hit`
142 carries the fraction along the segment, the point, the normal and the thing that
143 was hit, which is whatever you put in the list.
144
145 ```ts
146 // src/range/targets.ts
147 const hit = traceWorld(eye, to, run.targets.map((target) => target.box));
148 ```
149
150 ### grid
151
152 A `Grid` of cells of any type — the cell type is your alphabet, not the
153 engine's. `inside` and `cellAt` read it, `worldOf`, `colOf` and `rowOf` are the
154 cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells
155 into as few boxes as the shape allows, and `findPath` is A* with no
156 corner-cutting. Which cells are solid and which are walkable arrive as
157 predicates, so the engine never learns what a `"#"` means.
158
159 ```ts
160 // src/client/map.ts
161 export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to);
162 ```
163
164 ### mesh
165
166 `builder` opens a `Builder`, `addBox` writes six quads of positions, normals,
167 uvs and a layer into it, and `finish` packs it into a `Mesh` of typed arrays a
168 renderer uploads once. `quadMesh` is the full-screen quad for anything drawn
169 without geometry. Models are functions, not files.
170
171 ```ts
172 // src/client/models.ts
173 export function levelMesh(boxes: Box[]): Mesh {
174 const b = builder();
175 for (const box of boxes) addBox(b, box.min, box.max, box.mat);
176 return finish(b);
177 }
178 ```
179
180 ### texture
181
182 A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface.
183 `hash` is the integer hash under it, `noise` is one octave of value noise at a
184 period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into
185 one tightly packed RGBA buffer whose layers are the material indices the meshes
186 already carry.
187
188 ```ts
189 // src/client/materials.ts
190 export const materialTextures = (): Uint8Array => paintAtlas(MATERIALS, TEX_SIZE);
191 ```
192
193 ### sound
194
195 `createSoundDevice` gives a `SoundDevice` that opens its `AudioContext` on the
196 first sound, never at import — hand it a `Ctor` of your own and a test can count
197 what was played. It takes a `Burst` (band-passed noise: a gunshot, an impact)
198 and a `Tone` (a sine or square, optionally sliding). `attenuate` is the distance
199 curve: full volume inside the near radius and falling off after it, and
200 deliberately not inverse-square, because a game wants to hear the fight across
201 the map.
202
203 ```ts
204 // src/client/audio.ts
205 export const loudness = (at: Vec3, listener: Vec3): number =>
206 attenuate(dist(at, listener), EARSHOT, CLOSE);
207 ```
208
209 ### loop
210
211 `createLoop` takes `LoopOptions` — the tick, and optionally the most steps one
212 frame may run and the longest frame the clock is believed about — and `Frames`,
213 the clock and the frame source. The `Loop` it returns banks elapsed time and
214 hands back whole ticks, so a tab that slept for five seconds does not try to
215 simulate five seconds in one frame.
216
217 ```ts
218 // src/range/main.ts
219 const loop = createLoop({ tick: TICK }, {
220 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
221 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
222 now: () => performance.now(),
223 });
224 ```
225
226 ### controls
227
228 `createControls` takes the host and `Options` — the key table, the mouse table,
229 the keys whose default the page must not take, the look scales, the sensitivity,
230 the thumb-stick region and whether the mouse is ignored until the pointer is
231 locked — and returns `Controls`: the live held set, the `Stick`, a
232 `Controls.takeLook` that hands over the turn asked for since you last asked and
233 then forgets it, a `Controls.locked` flag, the `Controls.onAction` and
234 `Controls.onUnlock` callbacks, a `Controls.setAction` for pressing an action by
235 name, and a `Controls.detach` for when the game is over. `attachButtons` wires
236 every element carrying a `data-action` attribute to `Controls.setAction`, with
237 `ButtonOptions` saying which of them are a tap rather than a hold, so a thumb
238 reaches what a keyboard does. The event shapes it reads are exported too —
239 `KeyEventish`, `MouseEventish`, `TouchEventish`, `Touchish`, `Button` — because
240 a test drives the handlers with plain objects and no browser is in the room.
241
242 ```ts
243 // src/range/main.ts
244 const controls = createControls(
245 { surface: scene, window, document },
246 { keys: RANGE_KEYS, mouse: RANGE_MOUSE },
247 );
248 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP });
249 ```
250
251 ## The seam, named as pairs
252
253 Two games exist here, and every place they differ is a place the engine chose to
254 know nothing. Read down a column to see one game; read across a row to see the
255 split.
256
257 | the game supplies | the shooter | the range |
258 | --- | --- | --- |
259 | an entry | `src/client/main.ts` | `src/range/main.ts` |
260 | a host | a stub page in `src/client/stage.test-helper.ts` | a stub page in `src/range/dom.ts` |
261 | a bindings table | `src/client/binding.ts` — buy, scoreboard, weapon slots | `src/range/binding.ts` — a trigger and a start, nothing else |
262 | a tick rate | 64 Hz | 50 Hz |
263 | mesh recipes | `src/client/models.ts` | none: it draws no geometry yet |
264 | texture recipes | `src/client/materials.ts` | none: the readout paints its own 2D |
265 | sound recipes | `src/client/audio.ts` | none yet |
266 | a renderer | WebGL2, `src/client/renderer.ts` | a 2D horizon, `src/range/readout.ts` |
267 | the tuning | `src/client/physics.ts`, `src/client/weapons.ts` | `targets.STANDARD` in `src/range/targets.ts` |
268
269 ## What the engine deliberately does not know
270
271 - **The map layout.** The cell alphabet of `de_forge`, its areas, its crates,
272 its spawns and its sites are `src/client/map.ts`. The engine has a grid of
273 cells of an unknown type and two predicates.
274 - **The materials.** Which surface is sand and which is plaster, and how many
275 there are, is `src/client/materials.ts`. The engine has a function from a uv
276 to three numbers.
277 - **The rules of a round.** Freeze time, buy time, money, halves, what a kill is
278 worth: `src/client/match.ts` and `src/client/world.ts`, or `src/range/targets.ts`
279 for a thirty-second run of plates. The engine has a fixed step.
280 - **The database.** Scores, sessions and the leaderboard are `src/db.ts` and the
281 server. Nothing under `src/engine/` reads or writes anything.
282 - **The page.** HTML, ids, overlays, the security headers: `src/pages/` and
283 `src/server.ts`. The engine is handed elements and never looks one up.
284
285 That list is the reason the second game is a directory rather than a fork, and
286 it is worth defending: when something in a game starts to look general, move it behind
287 the door with a test, and when something behind the door starts to need to know
288 which game is running, it was never the engine's.
src/engine/boundary.test.ts +4 −1
@@ -215,8 +215,11 @@ test("no module in the engine goes through the engine's own door", () => {
215215 * The surface. This list is the engine's public API written down: a name that
216216 * arrives, leaves or is renamed fails here and is read in the pull. Sorted the
217217 * way `Array.prototype.sort` sorts, so capitals come first.
218 *
219 * Exported because `guide.test.ts` holds `GUIDE.md` against the same list: one
220 * copy of the surface, read twice, rather than two that drift.
218221 */
219 const SURFACE = [
222 export const SURFACE = [
220223 "Box", "Builder", "Burst", "Button", "ButtonOptions", "Controls", "Ctor", "Frames", "Grid",
221224 "Hit", "Host", "KeyEventish", "Listens", "LockOwner", "Loop", "LoopOptions", "Mat4", "Mesh",
222225 "MouseEventish", "Options", "Shader", "SoundDevice", "Stick", "Surface", "Tone",
src/engine/guide.test.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * The guide, held honest. `GUIDE.md` is the engine's how — what a game has to
3 * bring before the door's names are any use — and a page like that rots the
4 * moment it is only prose. Three rules keep it true, and they are the whole of
5 * this file:
6 *
7 * 1. Every name the door exports is named in the guide, so a group that lands
8 * behind the door arrives with a paragraph or it fails here.
9 * 2. Every engine name the guide writes in backticks is a name the door
10 * exports, so a renamed export cannot leave a lie on the page.
11 * 3. Every line of every code block is a line of the file named on the block's
12 * first line, so the examples are copied out of a game that exists here
13 * rather than invented for the page.
14 *
15 * **The backtick rule**, written here rather than left for a reader to guess:
16 * only inline spans count, never fenced code, and a span counts as a claim
17 * about the engine only when it is a bare identifier — letters, digits, `$` and
18 * `_`, nothing else. So a path (`src/range/binding.ts`), a qualified name
19 * (`binding.readIntent`, `Controls.takeLook`), a type applied to arguments and
20 * any span with a space in it are prose and are not checked. That leaves the
21 * words that are bare, not the engine's and have no module to carry them —
22 * the platform's own vocabulary, and the map's name — and those are written out
23 * in `NOT_THE_ENGINES` below, short enough to read.
24 *
25 * The list of exports is `SURFACE` from `boundary.test.ts`, imported rather
26 * than parsed a second time: one copy of the door's surface in this repository.
27 * Importing it registers that file's tests here again, which costs a
28 * millisecond and is what `purity.test.ts` and `src/range/independence.test.ts`
29 * already pay for the same reason.
30 */
31
32 import { expect, test } from "bun:test";
33 import { readFileSync } from "node:fs";
34 import { join, resolve } from "node:path";
35 import { SURFACE } from "./boundary.test.ts";
36
37 const ENGINE = import.meta.dir;
38 const ROOT = resolve(ENGINE, "..", "..");
39 const GUIDE = join(ENGINE, "GUIDE.md");
40
41 /** A bare identifier: the only shape of span this file reads as an engine name. */
42 const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
43
44 /**
45 * Bare backticked words that are nobody's export: the platform's vocabulary,
46 * and `de_forge`, which is the shooter's map and not a name at all. Anything
47 * else that is not the engine's is written as `module.name` in the guide, which
48 * this file ignores without needing to be told about it.
49 */
50 const NOT_THE_ENGINES = new Set([
51 "AudioContext", "Float32Array", "addEventListener", "cancelAnimationFrame", "de_forge",
52 "pointerLockElement", "removeEventListener", "requestAnimationFrame", "uniformMatrix4fv",
53 ]);
54
55 const guide = (): string => readFileSync(GUIDE, "utf8");
56
57 /** The markdown with every fenced block taken out: what the backtick rule reads. */
58 export function withoutFences(markdown: string): string {
59 return markdown.replace(/^```[\s\S]*?^```/gm, "");
60 }
61
62 /** Every inline code span, in order, under the backticks that made it. */
63 export function spansOf(markdown: string): string[] {
64 const out: string[] = [];
65 const pattern = /`([^`\n]+)`/g;
66 for (let m = pattern.exec(markdown); m !== null; m = pattern.exec(markdown))
67 out.push(m[1] as string);
68 return out;
69 }
70
71 /** One fenced block: the file its first line names, and the lines under it. */
72 export type Block = { file: string; lines: string[] };
73
74 /**
75 * Every fenced block, as the file it claims and the lines it quotes. A block
76 * whose first line is not `// <path>` comes back with an empty file name, which
77 * the test below turns into a sentence rather than passing quietly.
78 */
79 export function blocksOf(markdown: string): Block[] {
80 const out: Block[] = [];
81 const pattern = /^```[a-z]*\n([\s\S]*?)^```/gm;
82 for (let m = pattern.exec(markdown); m !== null; m = pattern.exec(markdown)) {
83 const lines = (m[1] as string).split("\n");
84 const first = (lines[0] ?? "").trim();
85 const named = /^\/\/ (\S+)$/.exec(first);
86 out.push({
87 file: named === null ? "" : (named[1] as string),
88 lines: lines.slice(1).filter((line) => line.trim() !== ""),
89 });
90 }
91 return out;
92 }
93
94 test("the guide is there and has the shape the rest of this file reads", () => {
95 const markdown = guide();
96 expect(markdown.length).toBeGreaterThan(2000);
97 expect(blocksOf(markdown).length).toBeGreaterThanOrEqual(8);
98 expect(spansOf(withoutFences(markdown)).length).toBeGreaterThan(50);
99 });
100
101 test("the guide names every name the door offers", () => {
102 const named = new Set(spansOf(withoutFences(guide())));
103 const offences = SURFACE.filter((name) => !named.has(name)).map(
104 (name) => `GUIDE.md never writes \`${name}\`; every name behind the door gets its paragraph`,
105 );
106 expect(offences).toEqual([]);
107 });
108
109 test("every engine name the guide writes in backticks is one the door exports", () => {
110 const offered = new Set(SURFACE);
111 const offences: string[] = [];
112 for (const span of new Set(spansOf(withoutFences(guide())))) {
113 if (!BARE.test(span) || offered.has(span) || NOT_THE_ENGINES.has(span)) continue;
114 offences.push(
115 `GUIDE.md writes \`${span}\` as an engine name and the door does not export it:` +
116 " qualify a game's own name as `module.name`, or add a platform word to NOT_THE_ENGINES",
117 );
118 }
119 expect(offences).toEqual([]);
120 });
121
122 test("every line the guide quotes is a line of the file it names", () => {
123 const offences: string[] = [];
124 for (const block of blocksOf(guide())) {
125 if (block.file === "") {
126 offences.push("a code block in GUIDE.md does not name its source on its first line");
127 continue;
128 }
129 let lines: Set<string>;
130 try {
131 lines = new Set(readFileSync(join(ROOT, block.file), "utf8").split("\n").map((l) => l.trim()));
132 } catch {
133 offences.push(`GUIDE.md quotes ${block.file}, which is not a file in this repository`);
134 continue;
135 }
136 for (const line of block.lines)
137 if (!lines.has(line.trim()))
138 offences.push(`GUIDE.md quotes "${line.trim()}" as ${block.file}; that file has no such line`);
139 }
140 expect(offences).toEqual([]);
141 });
142
143 test("the guide names the seam as a pair, both sides of it", () => {
144 const markdown = guide();
145 for (const pair of [
146 ["src/client/binding.ts", "src/range/binding.ts"],
147 ["src/client/main.ts", "src/range/main.ts"],
148 ["64 Hz", "50 Hz"],
149 ["src/client/materials.ts", "src/range/readout.ts"],
150 ])
151 for (const side of pair) expect(markdown).toContain(side);
152 });
153
154 test("the readers read what they exist to read", () => {
155 const fence = "```";
156 const markdown = [
157 "A path is `src/range/main.ts` and a member is `Controls.takeLook`.",
158 `${fence}ts`,
159 "// src/range/binding.ts",
160 "export const RANGE_MOUSE: Record<number, string> = { 0: \"fire\" };",
161 fence,
162 "A name is `rayBox`.",
163 ].join("\n");
164 // The fence and everything in it is gone before a span is read.
165 expect(spansOf(withoutFences(markdown))).toEqual([
166 "src/range/main.ts", "Controls.takeLook", "rayBox",
167 ]);
168 expect(blocksOf(markdown)).toEqual([
169 {
170 file: "src/range/binding.ts",
171 lines: ["export const RANGE_MOUSE: Record<number, string> = { 0: \"fire\" };"],
172 },
173 ]);
174 expect(blocksOf(`${fence}ts\nconst a = 1;\n${fence}`)[0]?.file).toBe("");
175 expect(BARE.test("rayBox")).toBe(true);
176 expect(BARE.test("binding.readIntent")).toBe(false);
177 expect(BARE.test("src/range/main.ts")).toBe(false);
178 expect(BARE.test("(u, v) => [r, g, b]")).toBe(false);
179 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs