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

forked from trevato/cs · proposes

The guide’s code runs: src/engine/example.ts is the smallest game on the package, and a test plays it #77

Merged opened this

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

Merged

torque/cs-engine:torque/58-the-guide-s-code-runs-src-engine-example → main 2 commits fork

  • Merged. torque/58-the-guide-s-code-runs-src-engine-example was merged into main as beadfc5.

Review

Sign in to review this change.
6 files changed +452 −116
README.md +10 −1
@@ -174,7 +174,16 @@ subtends — and the panel says which of the two you are looking at.
174174 entry, a host, a bindings table, a tick rate, its own recipes and its own
175175 tuning), a paragraph and one true line of code per group behind the door, and
176176 what the engine deliberately does not know. `guide.test.ts` holds it against
177 the door's list of names in both directions.
177 the door's list of names in both directions, and against the example below:
178 every code block on that page is a run of lines of `src/engine/example.ts`.
179 - `src/engine/example.ts` — the smallest game on the package, and the guide's
180 code made runnable: under eighty lines with one import, a room to stand in, a
181 plate to shoot, a bindings table, a 60 Hz tick and a `step(world, dt, held)`
182 that takes plain data and gives plain data back. It has no page, no renderer
183 and nothing saved — `src/range/` is the real second game — and
184 `example.test.ts` plays it headlessly: a seeded run scores the same twice, a
185 shot down the target's bearing hits and one twenty degrees off misses, and a
186 second of elapsed time is sixty ticks however the frames are cut.
178187 - `src/client/` — the game, in modules named for one thing each: `map` (de_forge
179188 itself: its areas, its crates, its cell alphabet and its spawns and sites, laid
180189 over the engine's grid), `physics`, `weapons`, `combat`, `match` (the rules),
src/engine/GUIDE.md +101 −107
@@ -1,22 +1,29 @@
11 # Building a game on this engine
22
3 Read `src/engine/example.ts` first: it is a whole game in one file — a room to
4 stand in, a plate to shoot, a score and a step — and every code block on this
5 page is a slice of it, so none of this is prose that quietly stopped compiling.
6
37 `index.ts` is the *what*: the list of names the door offers, one line of comment
48 per group. This is the *how*: what a game has to bring before those names are
59 any use, in the order you would actually write it.
610
711 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.
12 from a frame it owns, with a page it opened and numbers it chose. Two real games
13 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.
1116
1217 **Conventions.** A bare name in backticks — `rayBox`, `Mesh` — is the engine's,
1318 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.
19 `binding.readIntent`, `targets.step`, `example.play`. A path is a path. Every
20 code block names the file it came from on its first line, and that file is
21 always the example.
1622
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
23 `guide.test.ts` holds all four: every name the door exports is named here, every
24 bare engine name here is exported, every line of every code block is a line of
25 the file above it, and every block is a run of lines of `src/engine/example.ts`
26 in the order that file has them. So when a group lands behind the door that this
2027 page has no paragraph for — the physics of issue #3, one day — that test fails
2128 until the paragraph is written. That is the point. A name arrives with a page or
2229 it does not arrive.
@@ -28,20 +35,24 @@ it does not arrive.
2835 One directory, `src/<game>/`, and one file in it that is the frame:
2936 `src/range/main.ts` is the whole of the range's wiring. It exports the function
3037 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.
38 last two lines — the only place in the game a browser global is named. The
39 example's frame is `example.play`, and it is that function without the page:
3240
3341 ```ts
34 // src/range/main.ts
35 if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
36 else boot();
42 // src/engine/example.ts
43 export function play(host: Host, frames: Frames, seed: number): { world(): World; stop(): void } {
44 const controls = createControls(host, { keys: KEYS, requireLock: false });
45 const loop = createLoop({ tick: TICK }, frames);
46 let world = createWorld(seed);
3747 ```
3848
39 Two more things and the game is reachable: one entry in the registry in
49 Two more things and a real game is reachable: one entry in the registry in
4050 `src/games.ts` — its name, its path, its label, this source file and a size
4151 ceiling — and a page that server-renders the markup the entry looks up by id
4252 (`src/pages/range.ts`), named in the one map in `src/server.ts` that turns a
4353 game's name into its page function. The build, the header's nav and the 404
44 follow from the entry.
54 follow from the entry. The example has neither, on purpose: it is read and
55 played by a test, never shipped.
4556
4657 ### 2. A host
4758
@@ -53,16 +64,14 @@ parameter. Controls want a `Host` — `{ surface, window, document }`, where
5364 `cancelAnimationFrame`, and a `now()` in milliseconds.
5465
5566 ```ts
56 // src/client/main.ts
57 const controls = createControls(
58 { surface: sceneCanvas, window: win, document: doc },
59 { keys: CS_KEYS, mouse: CS_MOUSE, sensitivity: Number(sensField.value) || 2.2 },
60 );
67 // src/engine/example.ts
68 const controls = createControls(host, { keys: KEYS, requireLock: false });
6169 ```
6270
63 Those two are the shooter's own `main.Host`: the real browser in production,
64 and plain objects in `src/client/stage.test-helper.ts` under test. The range
65 does the same with `src/range/dom.ts`. Nothing in the engine knows the
71 That host is the real browser in production and plain objects under test: the
72 shooter's are in `src/client/stage.test-helper.ts`, the range's in
73 `src/range/dom.ts`, and the example's are a few objects that keep a list of
74 handlers at the top of `example.test.ts`. Nothing in the engine knows the
6675 difference, because those types are the members it reads and nothing wider.
6776
6877 ### 3. A bindings table
@@ -71,30 +80,29 @@ The engine collects action *names* and knows none of them. Which key says which
7180 name is the game's table, and it is data:
7281
7382 ```ts
74 // src/range/binding.ts
75 export const RANGE_KEYS: Record<string, string> = {
83 // src/engine/example.ts
84 export const KEYS: Record<string, string> = {
7685 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
77 ArrowUp: "forward", ArrowDown: "back", ArrowLeft: "left", ArrowRight: "right",
78 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
79 KeyF: "fire", KeyR: "reload", Enter: "start",
86 ArrowLeft: "turnLeft", ArrowRight: "turnRight", Space: "fire",
8087 };
8188 ```
8289
8390 Beside it go the mouse buttons, the on-screen buttons that are a tap rather than
8491 a hold, and one function from the held set to whatever struct the game's
85 simulation takes — `binding.readIntent` in both games, returning two different
86 shapes. The engine hands you a `ReadonlySet<string>` and a `Stick`; what
87 "forward" means is yours.
92 simulation takes — `binding.readIntent` in both real games, returning two
93 different shapes; in the example the step function reads the set itself, which
94 is all a game this small needs. The engine hands you a `ReadonlySet<string>` and
95 a `Stick`; what "forward" means is yours.
8896
8997 ### 4. A tick rate
9098
91 Pick the step the simulation runs at and never let a frame change it. The range
92 and the shooter disagree on purpose:
99 Pick the step the simulation runs at and never let a frame change it. The three
100 games here disagree on purpose — 64 Hz, 50 Hz and 60 Hz:
93101
94102 ```ts
95 // src/range/main.ts
96 /** The range's own step: 50 Hz, deliberately not the shooter's 64. */
97 const TICK = 1 / 50;
103 // src/engine/example.ts
104 /** The step the simulation runs at: 60 Hz, and no frame is allowed to change it. */
105 export const TICK = 1 / 60;
98106 ```
99107
100108 `Loop.advance(elapsed)` banks the frame and returns whole steps; a slow frame
@@ -107,8 +115,10 @@ There are no asset files in this repository and the engine ships none. It ships
107115 three tools that turn a recipe into bytes — a box builder, an atlas painter and
108116 a sound device — and the recipes belong to the game: `src/client/models.ts` is
109117 the shooter's geometry, `src/client/materials.ts` its nine surfaces,
110 `src/client/audio.ts` its voice. A second game writes its own three, or, like
111 the range today, writes none and draws in 2D.
118 `src/client/audio.ts` its voice. A second game writes its own three: the range's
119 are `src/range/level.ts`, `src/range/paint.ts` and `src/range/audio.ts`, and
120 they owe the shooter's nothing. The example writes none at all — it has one box
121 and one plate, and never draws or sounds either.
112122
113123 ### 6. Its own tuning
114124
@@ -116,6 +126,13 @@ Every number that decides how the game feels is the game's: eye height, hull
116126 size, gravity, how long a round lasts, what a plate is worth. The engine holds
117127 no constants of its own beyond the loop's two safety clamps. Put them at the top
118128 of a module, named, and a reader can tune the game without reading the engine.
129 The example's whole tuning is one line, and that is the shape to copy:
130
131 ```ts
132 // src/engine/example.ts
133 /** The tuning, all of it: metres per second, degrees per second, metres, metres. */
134 const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
135 ```
119136
120137 ## What is behind the door
121138
@@ -132,8 +149,8 @@ seeded generator — every random draw in a game that wants to be reproducible
132149 comes from one of these, in a fixed order.
133150
134151 ```ts
135 // src/range/main.ts
136 const shot = shoot(run, EYE, angleVectors(view.yaw, view.pitch).forward);
152 // src/engine/example.ts
153 const to = add(world.pos, scale(angleVectors(world.yaw, 0).forward, REACH));
137154 ```
138155
139156 ### trace
@@ -145,9 +162,12 @@ index is carried so the thing you hit knows what it is made of — and a `Hit`
145162 carries the fraction along the segment, the point, the normal and the thing that
146163 was hit, which is whatever you put in the list.
147164
165 A shot in the example is one ray and one box, and it is the whole of hitscan:
166 `src/range/targets.ts` does the same against a list with `traceWorld`.
167
148168 ```ts
149 // src/range/targets.ts
150 const hit = traceWorld(eye, to, run.targets.map((target) => target.box));
169 // src/engine/example.ts
170 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world;
151171 ```
152172
153173 ### grid
@@ -157,28 +177,18 @@ engine's. `inside` and `cellAt` read it, `worldOf`, `colOf` and `rowOf` are the
157177 cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells
158178 into as few boxes as the shape allows, and `findPath` is A* with no
159179 corner-cutting. Which cells are solid and which are walkable arrive as
160 predicates, so the engine never learns what a `"#"` means.
161
162 ```ts
163 // src/client/map.ts
164 export const findPath = (from: Vec3, to: Vec3): Vec3[] => g.findPath(grid, passable, from, to);
165 ```
180 predicates, so the engine never learns what a `"#"` means. The example has no
181 grid — one box is not a map — so read `src/client/map.ts`, which lays de_forge's
182 cell alphabet over this and hands back the level's boxes and its paths.
166183
167184 ### mesh
168185
169186 `builder` opens a `Builder`, `addBox` writes six quads of positions, normals,
170187 uvs and a layer into it, and `finish` packs it into a `Mesh` of typed arrays a
171188 renderer uploads once. `quadMesh` is the full-screen quad for anything drawn
172 without geometry. Models are functions, not files.
173
174 ```ts
175 // src/client/models.ts
176 export function levelMesh(boxes: Box[]): Mesh {
177 const b = builder();
178 for (const box of boxes) addBox(b, box.min, box.max, box.mat);
179 return finish(b);
180 }
181 ```
189 without geometry. Models are functions, not files: `src/client/models.ts` is the
190 shooter's eight of them and `src/range/level.ts` the range's two. The example
191 builds none, which is allowed — a game that never draws brings no geometry.
182192
183193 ### texture
184194
@@ -186,12 +196,9 @@ A `Shader` is `(u, v) => [r, g, b]`, and that is the whole of the interface.
186196 `hash` is the integer hash under it, `noise` is one octave of value noise at a
187197 period, `fbm` is a stack of them, and `paintAtlas` runs a list of shaders into
188198 one tightly packed RGBA buffer whose layers are the material indices the meshes
189 already carry.
190
191 ```ts
192 // src/client/materials.ts
193 export const materialTextures = (): Uint8Array => paintAtlas(MATERIALS, TEX_SIZE);
194 ```
199 already carry. `src/client/materials.ts` is the shooter's nine surfaces and
200 `src/range/paint.ts` the range's three layers, each painted once at start-up;
201 the example paints nothing, because it uploads no texture.
195202
196203 ### sound
197204
@@ -201,13 +208,9 @@ what was played. It takes a `Burst` (band-passed noise: a gunshot, an impact)
201208 and a `Tone` (a sine or square, optionally sliding). `attenuate` is the distance
202209 curve: full volume inside the near radius and falling off after it, and
203210 deliberately not inverse-square, because a game wants to hear the fight across
204 the map.
205
206 ```ts
207 // src/client/audio.ts
208 export const loudness = (at: Vec3, listener: Vec3): number =>
209 attenuate(dist(at, listener), EARSHOT, CLOSE);
210 ```
211 the map. `src/client/audio.ts` is the shooter's voice and `src/range/audio.ts`
212 the range's, and they share no sound; the example opens no device at all, which
213 is why a test can import it in a process with no `AudioContext` in it.
211214
212215 ### loop
213216
@@ -218,14 +221,19 @@ hands back whole ticks, so a tab that slept for five seconds does not try to
218221 simulate five seconds in one frame.
219222
220223 ```ts
221 // src/range/main.ts
222 const loop = createLoop({ tick: TICK }, {
223 requestAnimationFrame: (callback) => requestAnimationFrame(callback),
224 cancelAnimationFrame: (handle) => cancelAnimationFrame(handle),
225 now: () => performance.now(),
224 // src/engine/example.ts
225 const loop = createLoop({ tick: TICK }, frames);
226 let world = createWorld(seed);
227 loop.start((elapsed) => {
228 for (let steps = loop.advance(elapsed); steps > 0; steps--) world = step(world, TICK, controls.held);
226229 });
227230 ```
228231
232 In a browser those `Frames` are three one-line functions over
233 `requestAnimationFrame`, `cancelAnimationFrame` and `performance.now` —
234 `src/range/main.ts` writes them out — and in `example.test.ts` they are a
235 number the test moves, which is how a second of play takes a millisecond.
236
229237 ### controls
230238
231239 `createControls` takes the host and `Options` — the key table, the mouse table,
@@ -242,13 +250,14 @@ reaches what a keyboard does. The event shapes it reads are exported too —
242250 `KeyEventish`, `MouseEventish`, `TouchEventish`, `Touchish`, `Button` — because
243251 a test drives the handlers with plain objects and no browser is in the room.
244252
253 What a game does with all that can be one line — read the held set every tick
254 and be done — and `attachButtons` is a second line whenever there are on-screen
255 buttons to wire, which `src/range/main.ts` has and the example does not:
256
245257 ```ts
246 // src/range/main.ts
247 const controls = createControls(
248 { surface: scene, window, document },
249 { keys: RANGE_KEYS, mouse: RANGE_MOUSE },
250 );
251 attachButtons(document.querySelectorAll<HTMLElement>("[data-action]"), controls, { tap: RANGE_TAP });
258 // src/engine/example.ts
259 export function step(world: World, dt: number, held: ReadonlySet<string>): World {
260 const on = (action: string): number => (held.has(action) ? 1 : 0);
252261 ```
253262
254263 ### overlay
@@ -268,13 +277,8 @@ tools touch, and not a whole context. A browser's is one; so is a recorder in a
268277 test, which is how every pixel decision in `overlay.test.ts` is checked with no
269278 canvas in the room, and how `src/range/readout.test.ts` checks the range's
270279 panel. There is no colour in here a game chose, beyond one neutral scrim.
271
272 ```ts
273 // src/range/readout.ts
274 const o = overlay(paint, width, height);
275 o.panel(pad, pad, width - pad * 2, step * lines.length + pad, "rgba(11,13,17,.72)");
276 o.crosshair(Math.round(width / 2), Math.round(height / 2), 0, Math.max(6, Math.round(width / 90)), "#8ea");
277 ```
280 `src/range/readout.ts` is a whole panel drawn through it; the example draws
281 nothing at all, because a game that draws is a game with a page.
278282
279283 ### renderer
280284
@@ -295,17 +299,10 @@ name, under the name its instances will ask for.
295299 `RendererAssets.sky` is the horizon for a frame that names none, and
296300 `RendererAssets.devicePixelRatio` is `window.devicePixelRatio` handed in as a
297301 function, because the engine reads no global and a test drives a whole frame
298 with no window in the room.
299
300 ```ts
301 // src/client/main.ts
302 createRenderer(canvas as unknown as HTMLCanvasElement, {
303 atlas: { bytes: materialTextures(), size: TEX_SIZE, layers: LAYERS },
304 meshes: gameMeshes(),
305 sky: SKY,
306 devicePixelRatio: () => window.devicePixelRatio,
307 }),
308 ```
302 with no window in the room. Two games here build all of that and both are the
303 shape to copy: the shooter's atlas and eight meshes in `src/client/main.ts`, and
304 the range's atlas and two in `src/range/scene.ts`, handed to the call in
305 `src/range/main.ts`.
309306
310307 Then every frame is a `Scene`: a `Camera` — a position, a yaw, a pitch and a
311308 horizontal field of view — the sky this frame clears to and fades into, a pool
@@ -317,18 +314,15 @@ gun in your hands. Fill one scene object every frame rather than making one —
317314 `src/client/scene.ts` allocates nothing per frame and is worth copying — name a
318315 mesh that was never handed in and the draw throws with that name in the
319316 message, and remember that nothing in a draw animates itself: a bob, a fade or
320 a tint is a decision, and decisions are the game's.
321
322 ```ts
323 // src/client/main.ts
324 renderer.draw(buildScene(world, camera, frameScene), elapsed);
325 ```
317 a tint is a decision, and decisions are the game's. One line a frame draws it,
318 and `src/client/main.ts` and `src/range/main.ts` are where those two lines live.
326319
327320 ## The seam, named as pairs
328321
329 Two games exist here, and every place they differ is a place the engine chose to
322 Two games ship here, and every place they differ is a place the engine chose to
330323 know nothing. Read down a column to see one game; read across a row to see the
331 split.
324 split. The example is in no column: it brings a host, a table, a tick and its
325 own tuning, and nothing else in this table at all.
332326
333327 | the game supplies | the shooter | the range |
334328 | --- | --- | --- |
src/engine/boundary.test.ts +24 −7
@@ -25,6 +25,18 @@ const ROOT = resolve(ENGINE, "..", "..");
2525 /** The directories outside the engine whose imports this test is about. */
2626 const OUTSIDE = ["src", "scripts"];
2727
28 /**
29 * A file under the engine that is not a module of it: a test, the probe
30 * `purity.test.ts` spawns, and `example.ts`, the guide's game — documentation
31 * that ships in no bundle and is the one file in here that goes through the
32 * door on purpose, because the whole point of it is to use this package the way
33 * a game outside it does. The two tests that read this are the two it is an
34 * exception to, and `example.ts` pays for its exception one test below: it
35 * imports the door and nothing else.
36 */
37 const notAModule = (file: string): boolean =>
38 file.endsWith(".test.ts") || file.endsWith(".probe.ts") || file === "example.ts";
39
2840 /** Every `.ts` under the engine, recursively, as paths relative to it. */
2941 export function sources(dir = ENGINE): string[] {
3042 const out: string[] = [];
@@ -186,11 +198,9 @@ test("the door rule is about the door and nothing else", () => {
186198 test("every module in the engine is behind the door, and the door holds nothing else", () => {
187199 const door = readFileSync(join(ENGINE, "index.ts"), "utf8");
188200 const behind = new Set(specifiersOf(door).map((s) => s.replace(/^\.\//, "")));
189 // A `.probe.ts` is tooling the tests spawn, not a module that ships, so the
190 // door neither offers it nor should: `purity.test.ts` draws the same line.
191 const modules = sources().filter(
192 (f) => !f.endsWith(".test.ts") && !f.endsWith(".probe.ts") && f !== "index.ts",
193 );
201 // A `.probe.ts` is tooling the tests spawn and `example.ts` is the guide's
202 // game, so the door offers neither and should not: `notAModule` says why.
203 const modules = sources().filter((f) => !notAModule(f) && f !== "index.ts");
194204 expect(modules.length).toBeGreaterThan(0);
195205 expect([...behind].sort()).toEqual(modules);
196206 // Nothing of its own: the door is re-exports, a docstring and nothing else.
@@ -201,8 +211,10 @@ test("every module in the engine is behind the door, and the door holds nothing
201211 test("no module in the engine goes through the engine's own door", () => {
202212 const offences: string[] = [];
203213 for (const file of sources()) {
204 // The door itself, and a test that reaches through it to check what it holds.
205 if (file === "index.ts" || file.endsWith(".test.ts")) continue;
214 // The door itself; a test, which reaches through it to check what it holds;
215 // and the example, which goes through it because it is a game. Everything
216 // else in here, the probe included, imports its siblings directly.
217 if (file === "index.ts" || file.endsWith(".test.ts") || file === "example.ts") continue;
206218 const source = readFileSync(join(ENGINE, file), "utf8");
207219 for (const specifier of specifiersOf(source))
208220 if (relative(ENGINE, resolve(ENGINE, join(file, ".."), specifier)) === "index.ts")
@@ -211,6 +223,11 @@ test("no module in the engine goes through the engine's own door", () => {
211223 expect(offences).toEqual([]);
212224 });
213225
226 test("the example imports the door and nothing else, which is what it is for", () => {
227 const source = readFileSync(join(ENGINE, "example.ts"), "utf8");
228 expect(specifiersOf(source)).toEqual(["./index.ts"]);
229 });
230
214231 /**
215232 * The surface. This list is the engine's public API written down: a name that
216233 * arrives, leaves or is renamed fails here and is read in the pull. Sorted the
src/engine/example.test.ts added +179
@@ -0,0 +1,179 @@
1 /**
2 * The guide's game, played. `example.ts` is the smallest thing that can be
3 * called a game on this package, and it is only worth keeping if it runs: this
4 * file runs it with no browser in the room — a stub page, a stub clock and a
5 * script of held keys — so a door whose exports still have the right names and
6 * no longer fit together fails here rather than in a preview.
7 *
8 * Four things are pinned, and they are the four the example exists to show: a
9 * seeded run plays the same twice, a shot down the target's bearing hits and
10 * one twenty degrees off does not, a second of elapsed time is the same number
11 * of fixed steps however the frames are cut, and the bindings table is what
12 * turns a key press into the action `step` reads.
13 */
14
15 import { expect, test } from "bun:test";
16 import { KEYS, ROOM, TICK, createWorld, play, step, type World } from "./example.ts";
17 import type { Frames, Host } from "./index.ts";
18
19 /** Degrees of turn one tick is worth: the example's 120°/s at its own tick. */
20 const TURN_PER_TICK = 120 * TICK;
21
22 /** How many ticks a world has lived, read out of the only thing that counts them. */
23 const ticksOf = (world: World): number => Math.round((world.yaw - 90) / TURN_PER_TICK);
24
25 /** The yaw, in degrees, from the eye to the middle of the plate. */
26 function bearingOf(world: World): number {
27 const [x, y] = [
28 (world.target.min[0] + world.target.max[0]) / 2 - world.pos[0],
29 (world.target.min[1] + world.target.max[1]) / 2 - world.pos[1],
30 ];
31 return (Math.atan2(y, x) * 180) / Math.PI;
32 }
33
34 /** One target a page listens on, and a way to fire an event at it by hand. */
35 function target(extra: Record<string, unknown> = {}) {
36 const handlers = new Map<string, ((event: unknown) => void)[]>();
37 return {
38 ...extra,
39 addEventListener(type: string, handler: (event: unknown) => void) {
40 handlers.set(type, [...(handlers.get(type) ?? []), handler]);
41 },
42 removeEventListener(type: string, handler: (event: unknown) => void) {
43 handlers.set(type, (handlers.get(type) ?? []).filter((h) => h !== handler));
44 },
45 fire(type: string, event: Record<string, unknown>) {
46 for (const handler of handlers.get(type) ?? []) handler({ preventDefault() {}, ...event });
47 },
48 listening: (): number => [...handlers.values()].reduce((n, list) => n + list.length, 0),
49 };
50 }
51
52 /** A page and a clock: everything `play` is handed, and none of it a browser. */
53 function stage() {
54 const host = {
55 surface: target({ clientWidth: 800 }),
56 window: target(),
57 document: target({ pointerLockElement: null }),
58 };
59 let callback: ((now: number) => void) | null = null;
60 let now = 0;
61 let handle = 0;
62 const frames: Frames = {
63 requestAnimationFrame(next) {
64 callback = next;
65 return (handle += 1);
66 },
67 cancelAnimationFrame() {
68 callback = null;
69 },
70 now: () => now,
71 };
72 /** One frame of `ms` milliseconds, delivered the way a browser delivers one. */
73 const frame = (ms: number): void => {
74 now += ms;
75 callback?.(now);
76 };
77 return { host: host as unknown as Host, page: host, frames, frame };
78 }
79
80 /** A scripted player: turn onto the plate, then pull the trigger every other tick. */
81 function scripted(seed: number, ticks: number): World {
82 let world = createWorld(seed);
83 for (let i = 0; i < ticks; i++) {
84 const off = ((bearingOf(world) - world.yaw + 540) % 360) - 180;
85 const held = new Set<string>();
86 if (off > 1) held.add("turnLeft");
87 else if (off < -1) held.add("turnRight");
88 else if (i % 2 === 0) held.add("fire");
89 world = step(world, TICK, held);
90 }
91 return world;
92 }
93
94 test("a seeded run plays the same twice, down to the last plate", () => {
95 const once = scripted(1234, 600);
96 expect(once.score).toBeGreaterThan(0);
97 expect(scripted(1234, 600)).toEqual(once);
98 // And the seed is the run: a different one is a different set of plates.
99 expect(scripted(9876, 600).target).not.toEqual(once.target);
100 });
101
102 test("a shot down the target's bearing hits, and one twenty degrees off misses", () => {
103 const world = createWorld(42);
104 const fire = new Set(["fire"]);
105 expect(step({ ...world, yaw: bearingOf(world) }, TICK, fire).score).toBe(1);
106 expect(step({ ...world, yaw: bearingOf(world) + 20 }, TICK, fire).score).toBe(0);
107 expect(step({ ...world, yaw: bearingOf(world) - 20 }, TICK, fire).score).toBe(0);
108 // A hit puts up a new plate; a miss leaves the one that was there.
109 expect(step({ ...world, yaw: bearingOf(world) }, TICK, fire).target).not.toEqual(world.target);
110 expect(step({ ...world, yaw: bearingOf(world) + 20 }, TICK, fire).target).toEqual(world.target);
111 });
112
113 test("the trigger is an edge: a held key is one shot, not one a tick", () => {
114 const aimed = { ...createWorld(42), yaw: bearingOf(createWorld(42)) };
115 const fire = new Set(["fire"]);
116 let world = aimed;
117 for (let i = 0; i < 30; i++) world = step({ ...world, yaw: bearingOf(world) }, TICK, fire);
118 expect(world.score).toBe(1);
119 });
120
121 test("a second of elapsed time is the same number of ticks however it is cut", () => {
122 const run = (frameMs: number, count: number): number => {
123 const { host, page, frames, frame } = stage();
124 const session = play(host, frames, 7);
125 page.window.fire("keydown", { code: "ArrowLeft" });
126 for (let i = 0; i < count; i++) frame(frameMs);
127 const ticks = ticksOf(session.world());
128 session.stop();
129 return ticks;
130 };
131 // A second at 60 Hz is sixty ticks, whether it arrives in ten frames or a
132 // hundred. None of these is a frame exactly one tick long, which is a float
133 // knife-edge the loop is allowed to bank rather than step.
134 expect(run(100, 10)).toBe(60);
135 expect(run(10, 100)).toBe(60);
136 expect(run(20, 50)).toBe(60);
137 // A frame nobody believes is clamped rather than simulated: at most the
138 // loop's eight steps, which is why a slept tab does not catch up in one go.
139 expect(run(5000, 1)).toBe(8);
140 });
141
142 test("the bindings table is what turns a key into the action the step reads", () => {
143 const { host, page, frames, frame } = stage();
144 const session = play(host, frames, 7);
145 // KeyW is "forward" in the table and the world starts facing north.
146 page.window.fire("keydown", { code: "KeyW" });
147 for (let i = 0; i < 10; i++) frame(100);
148 const walked = session.world();
149 expect(walked.pos[1]).toBeGreaterThan(createWorld(7).pos[1]);
150 expect(walked.pos[0]).toBeCloseTo(0, 6);
151 // A key the table does not name is collected by nobody and moves nothing.
152 page.window.fire("keyup", { code: "KeyW" });
153 page.window.fire("keydown", { code: "KeyQ" });
154 for (let i = 0; i < 10; i++) frame(100);
155 expect(session.world().pos).toEqual(walked.pos);
156 expect(KEYS.KeyQ).toBeUndefined();
157 session.stop();
158 });
159
160 test("the player stays in the one box there is to stand in", () => {
161 const { host, page, frames, frame } = stage();
162 const session = play(host, frames, 7);
163 page.window.fire("keydown", { code: "KeyW" });
164 for (let i = 0; i < 100; i++) frame(100);
165 const pos = session.world().pos;
166 expect(pos[1]).toBeCloseTo((ROOM.max[1] as number) - 0.5, 6);
167 expect(pos[2]).toBe(1.6);
168 session.stop();
169 });
170
171 test("the session lets the page go when it stops", () => {
172 const { host, page, frames } = stage();
173 const session = play(host, frames, 7);
174 expect(page.window.listening()).toBeGreaterThan(0);
175 session.stop();
176 expect(page.window.listening()).toBe(0);
177 expect(page.surface.listening()).toBe(0);
178 expect(page.document.listening()).toBe(0);
179 });
src/engine/example.ts added +79
@@ -0,0 +1,79 @@
1 /**
2 * The smallest game on this package: one room, one plate, one score and a step
3 * a test can play ten thousand times. It exists so the guide beside it can be
4 * executed — every fenced block in `GUIDE.md` is a slice of these lines, and
5 * `guide.test.ts` fails the day the two drift apart. It is documentation and
6 * not a third product: no renderer, no page, no persistence, nothing opened and
7 * nothing run at import. `src/range/` is the real second game, and this file is
8 * smaller than the wiring of one on purpose.
9 */
10
11 import {
12 add, angleVectors, clamp, createControls, createLoop, rayBox, rng, scale,
13 type Box, type Frames, type Host, type Vec3,
14 } from "./index.ts";
15
16 /** `KeyboardEvent.code` to action name: this game's whole table, and it is data. */
17 export const KEYS: Record<string, string> = {
18 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
19 ArrowLeft: "turnLeft", ArrowRight: "turnRight", Space: "fire",
20 };
21
22 /** The step the simulation runs at: 60 Hz, and no frame is allowed to change it. */
23 export const TICK = 1 / 60;
24
25 /** The room, in metres: one box, and the player is held half a metre inside it. */
26 export const ROOM: Box = { min: [-4, -4, 0], max: [4, 4, 3], mat: 0 };
27
28 /** The tuning, all of it: metres per second, degrees per second, metres, metres. */
29 const SPEED = 4, TURN = 120, EYE = 1.6, REACH = 40;
30
31 /** Everything this game is, and all of it plain: it prints, copies and compares. */
32 export type World = {
33 seed: number; pos: Vec3; yaw: number; score: number; firing: boolean; target: Box;
34 };
35
36 /** A plate across the far wall from one number, and the number after it: `rng`
37 * hands back its own next state over 2^32, so the multiply recovers that state. */
38 function plate(seed: number): [Box, number] {
39 const u = rng(seed)();
40 return [{ min: [u * 6 - 3.25, 3.7, 1.2], max: [u * 6 - 2.75, 3.8, 1.9], mat: 1 }, (u * 2 ** 32) >>> 0];
41 }
42
43 export function createWorld(seed: number): World {
44 const [target, next] = plate(seed);
45 return { seed: next, pos: [0, -3, EYE], yaw: 90, score: 0, firing: false, target };
46 }
47
48 /** One shot down the view: one ray, one box, and a point if it lands. */
49 function shoot(world: World): World {
50 const to = add(world.pos, scale(angleVectors(world.yaw, 0).forward, REACH));
51 if (rayBox(world.pos, to, world.target.min, world.target.max) === null) return world;
52 const [target, seed] = plate(world.seed);
53 return { ...world, score: world.score + 1, target, seed };
54 }
55
56 /** One tick: a world, the seconds and the held actions in, a new world out. */
57 export function step(world: World, dt: number, held: ReadonlySet<string>): World {
58 const on = (action: string): number => (held.has(action) ? 1 : 0);
59 const yaw = world.yaw + (on("turnLeft") - on("turnRight")) * TURN * dt;
60 const { forward, right } = angleVectors(yaw, 0);
61 const walk = add(scale(forward, on("forward") - on("back")), scale(right, on("right") - on("left")));
62 const put = (x: number, d: number): number => clamp(x + d * SPEED * dt, ROOM.min[0] + 0.5, ROOM.max[0] - 0.5);
63 const pos: Vec3 = [put(world.pos[0], walk[0]), put(world.pos[1], walk[1]), EYE];
64 const fire = held.has("fire");
65 const next = { ...world, pos, yaw, firing: fire };
66 return fire && !world.firing ? shoot(next) : next; // one shot per press, not per tick
67 }
68
69 /** The frame: the host and the clock are handed in, the page's in a game and
70 * plain objects in a test, so nothing in here looks anything up. */
71 export function play(host: Host, frames: Frames, seed: number): { world(): World; stop(): void } {
72 const controls = createControls(host, { keys: KEYS, requireLock: false });
73 const loop = createLoop({ tick: TICK }, frames);
74 let world = createWorld(seed);
75 loop.start((elapsed) => {
76 for (let steps = loop.advance(elapsed); steps > 0; steps--) world = step(world, TICK, controls.held);
77 });
78 return { world: () => world, stop: () => { loop.stop(); controls.detach(); } };
79 }
src/engine/guide.test.ts +59 −1
@@ -1,7 +1,7 @@
11 /**
22 * The guide, held honest. `GUIDE.md` is the engine's how — what a game has to
33 * 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
4 * moment it is only prose. Four rules keep it true, and they are the whole of
55 * this file:
66 *
77 * 1. Every name the door exports is named in the guide, so a group that lands
@@ -11,6 +11,18 @@
1111 * 3. Every line of every code block is a line of the file named on the block's
1212 * first line, so the examples are copied out of a game that exists here
1313 * rather than invented for the page.
14 * 4. Every code block is a run of lines of `example.ts` — the smallest game on
15 * this package, which a test plays — so the page cannot drift away from code
16 * that runs.
17 *
18 * **The block rule**, written here rather than left for a reader to guess: a
19 * block matches when its lines, each trimmed and the blank ones dropped, appear
20 * one after another in `example.ts`, whose lines are read the same way. So a
21 * block may quote any contiguous slice of that file, long or short, indented
22 * however markdown wanted it, and blocks may be quoted in any order the page
23 * reads best in — but a block may not stitch two distant lines together and
24 * imply they sit next to each other. When it fails, the message is the first
25 * line of the block that is not where the block says it is.
1426 *
1527 * **The backtick rule**, written here rather than left for a reader to guess:
1628 * only inline spans count, never fenced code, and a span counts as a claim
@@ -37,6 +49,7 @@ import { SURFACE } from "./boundary.test.ts";
3749 const ENGINE = import.meta.dir;
3850 const ROOT = resolve(ENGINE, "..", "..");
3951 const GUIDE = join(ENGINE, "GUIDE.md");
52 const EXAMPLE = join(ENGINE, "example.ts");
4053
4154 /** A bare identifier: the only shape of span this file reads as an engine name. */
4255 const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
@@ -92,6 +105,28 @@ export function blocksOf(markdown: string): Block[] {
92105 return out;
93106 }
94107
108 /** A file's lines the way the block rule reads them: trimmed, the blank gone. */
109 export const codeLines = (source: string): string[] =>
110 source.split("\n").map((line) => line.trim()).filter((line) => line !== "");
111
112 /**
113 * The first line of `block` that is not where the block says it is, reading the
114 * block as a run of consecutive lines of `file`, or null when it is one. The
115 * line reported is the one after the longest run that did match, which is the
116 * line a reader has to look at.
117 */
118 export function firstLineAstray(block: string[], file: string[]): string | null {
119 if (block.length === 0) return null;
120 let matched = 0;
121 for (let i = 0; i < file.length; i++) {
122 let run = 0;
123 while (run < block.length && file[i + run] === block[run]) run += 1;
124 if (run === block.length) return null;
125 if (run > matched) matched = run;
126 }
127 return block[matched] as string;
128 }
129
95130 test("the guide is there and has the shape the rest of this file reads", () => {
96131 const markdown = guide();
97132 expect(markdown.length).toBeGreaterThan(2000);
@@ -141,6 +176,20 @@ test("every line the guide quotes is a line of the file it names", () => {
141176 expect(offences).toEqual([]);
142177 });
143178
179 test("every block the guide quotes is a run of lines of the example that runs", () => {
180 const file = codeLines(readFileSync(EXAMPLE, "utf8"));
181 const offences: string[] = [];
182 for (const block of blocksOf(guide())) {
183 const astray = firstLineAstray(block.lines.map((line) => line.trim()), file);
184 if (astray !== null)
185 offences.push(
186 `GUIDE.md quotes "${astray}", which src/engine/example.ts does not have` +
187 " where the block puts it: every block is a run of that file's lines, in its order",
188 );
189 }
190 expect(offences).toEqual([]);
191 });
192
144193 test("the guide names the seam as a pair, both sides of it", () => {
145194 const markdown = guide();
146195 for (const pair of [
@@ -173,6 +222,15 @@ test("the readers read what they exist to read", () => {
173222 },
174223 ]);
175224 expect(blocksOf(`${fence}ts\nconst a = 1;\n${fence}`)[0]?.file).toBe("");
225 const file = codeLines("const a = 1;\n\n const b = 2;\nconst c = 3;\n");
226 expect(file).toEqual(["const a = 1;", "const b = 2;", "const c = 3;"]);
227 // A slice is a slice wherever it starts, and a blank line in the block is not
228 // a gap; two lines that are not neighbours in the file are not a slice.
229 expect(firstLineAstray(["const b = 2;", "const c = 3;"], file)).toBeNull();
230 expect(firstLineAstray(["const a = 1;", "const c = 3;"], file)).toBe("const c = 3;");
231 expect(firstLineAstray(["const c = 3;", "const b = 2;"], file)).toBe("const b = 2;");
232 expect(firstLineAstray(["const d = 4;"], file)).toBe("const d = 4;");
233 expect(firstLineAstray([], file)).toBeNull();
176234 expect(BARE.test("rayBox")).toBe(true);
177235 expect(BARE.test("binding.readIntent")).toBe(false);
178236 expect(BARE.test("src/range/main.ts")).toBe(false);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs