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

forked from trevato/cs · proposes

procedurally generated maps #147

Merged opened this in answer to

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

Merged

vesper/cs-engine:vesper/138-procedurally-generated-maps → main 3 commits fork

  • Merged. vesper/138-procedurally-generated-maps was merged into main as 832812d.

Review

Sign in to review this change.
23 files changed +1063 −230
README.md +17 −3
@@ -32,6 +32,15 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
3232 CS damage with range falloff, armour and hitgroups, deterministic spray patterns,
3333 a 1:55 round with a 40-second bomb, plant and defuse timers, kill rewards and
3434 loss bonuses. The leaderboard is a real SQLite table under `FORGE_APP_DATA`.
35 - **Two kinds of map.** `de_forge` is drawn by hand, cell by cell; the other
36 kind is carved from a number by `src/client/generate.ts` — pick *generated*
37 on the menu and the match id is the seed, so every match is a layout nobody
38 has played. A generated map is stamped twice, once at each cell and once half
39 a circle away with T for CT and A for B, so it is the same map for both sides
40 and the walk to your site is the walk to theirs, measured to the unit. It is
41 not shipped until it passes its own gate: spawns to stand on, two sites to
42 plant on, a path from each spawn to each site, and no floor that cannot be
43 walked to. A map costs 2.1 ms to make, which is the press of the Play button.
3544 - **Generated, not licensed.** The map (`de_forge`), the textures, the player and
3645 weapon models and every sound are produced in code at start-up, by four
3746 generic tools in the engine — a grid of cells that meshes and paths, a box mesh
@@ -268,9 +277,14 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
268277 level *is* to this game — a grid of lettered cells, the predicate that says
269278 which letters you can stand on, the boxes, the spawns, the sites, the wall
270279 height and the material table, plus the questions asked of one: `walkable`,
271 `siteAt`, `findPath`), `map` (de_forge itself: its areas, its crates, its cell
272 alphabet and its spawns and sites, laid over the engine's grid, exported as
273 `DE_FORGE` — one value of `Level` and the only one that ships),
280 `siteAt`, `findPath`), `cells` (the alphabet both maps are
281 written in — the letters, the cell size, the wall height, the materials and
282 the predicate — and `levelOf`, which reads a page of those letters into a
283 `Level`), `map` (de_forge itself: its areas and its crates, laid over the
284 engine's grid, exported as `DE_FORGE`), `generate` (a map from a number:
285 rooms, corridors, two sites, two spawns and cover, every cell stamped twice
286 so the map is its own opponent's map turned half a circle, and a gate that
287 refuses a layout a round cannot be played on),
274288 `movement` (Counter-Strike's numbers, and nothing else: `CS_MOVE`, `CS_HULL`
275289 and `fallDamage`, handed to the engine's mover on every call — the solver
276290 itself went behind the door in #123 and holds none of them), `weapons`,
scripts/build.test.ts +1 −1
@@ -37,7 +37,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3737
3838 test("the entry table is what the app ships today, each with the size it may not pass", () => {
3939 expect(ENTRIES).toEqual([
40 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 75_400, gzip: 34_900 } },
40 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 82_000, gzip: 38_000 } },
4141 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 33_000, gzip: 14_000 } },
4242 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
4343 ]);
src/client/cells.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * The alphabet every map of this game is written in, and what a page of it
3 * becomes. A cell is one letter — rock, floor, a crate you can climb, a crate
4 * you cannot, the two bombsites, the two spawns — and `levelOf` turns a grid of
5 * those letters into a `Level`: the boxes a bullet stops in and the renderer
6 * draws, the spawn points, the middle of each site, and the predicate that says
7 * what a bot may stand on.
8 *
9 * It exists because there are two maps now and there will be more. `map.ts`
10 * writes de_forge's page by hand and `generate.ts` writes one from a seed;
11 * neither of them owns what a letter means, so neither of them can disagree
12 * with the other about it. What a level *is* — the type, and the questions
13 * asked of one every tick — is `level.ts`, which knows no alphabet at all, and
14 * the arithmetic under this file is the engine's `grid.ts`, which knows less
15 * still: what counts as solid or walkable goes in as a predicate.
16 */
17
18 import * as g from "../engine/index.ts";
19 import type { Box, Grid, Vec3 } from "../engine/index.ts";
20 import type { Level } from "./level.ts";
21
22 /** World units along one side of a cell: Source units, like everything here. */
23 export const CELL = 128;
24
25 /** How tall a wall stands. Nothing in this game can see or shoot over one. */
26 export const WALL_HEIGHT = 384;
27
28 /**
29 * One letter of a map. `#` rock, `.` floor, `x` a crate you can jump onto, `X`
30 * one you cannot, `A` and `B` the two bombsites, `t` and `c` the two spawns.
31 */
32 export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c";
33
34 /** The material ids this game's boxes are tagged with, by name. */
35 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
36
37 /**
38 * Solid to a walking bot: walls and both kinds of crate. This predicate is the
39 * whole of what the engine's search knows about this alphabet, and it is the
40 * one thing a level carries that is a function rather than a number.
41 */
42 export const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X";
43
44 /** The bounding slab of every cell paved with one letter, two units thick. */
45 function siteSlab(grid: Grid<Cell>, fill: Cell): Box {
46 let c0 = grid.width, r0 = grid.height, c1 = 0, r1 = 0;
47 for (let r = 0; r < grid.height; r++)
48 for (let c = 0; c < grid.width; c++)
49 if (grid.cells[r * grid.width + c] === fill) {
50 c0 = Math.min(c0, c); c1 = Math.max(c1, c);
51 r0 = Math.min(r0, r); r1 = Math.max(r1, r);
52 }
53 if (c1 < c0) throw new Error(`no cell of this map is paved "${fill}": it has no site to plant on`);
54 const a = g.worldOf(grid, c0, r0), b = g.worldOf(grid, c1, r1);
55 return {
56 min: [a[0] - CELL / 2, b[1] - CELL / 2, 0],
57 max: [b[0] + CELL / 2, a[1] + CELL / 2, 2],
58 mat: MAT.SITE,
59 };
60 }
61
62 /** Every cell paved with one letter, as world points, in reading order. */
63 export function cellsOf(grid: Grid<Cell>, fill: Cell): Vec3[] {
64 const out: Vec3[] = [];
65 for (let r = 0; r < grid.height; r++)
66 for (let c = 0; c < grid.width; c++)
67 if (grid.cells[r * grid.width + c] === fill) out.push(g.worldOf(grid, c, r));
68 return out;
69 }
70
71 function centre(list: Vec3[]): Vec3 {
72 const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3);
73 return [s[0] / list.length, s[1] / list.length, 0];
74 }
75
76 /**
77 * A page of letters, read as a level: the floor slab, the two painted sites,
78 * the walls and both heights of crate merged into as few boxes as the shape
79 * allows, the spawn points and the middle of each site.
80 *
81 * One piece of data feeds everything — what you collide with, what gets drawn,
82 * where bots walk, where the bomb can be planted — so a map can never disagree
83 * with itself. A grid with no `A`, no `B`, or no cell for a side to spawn on is
84 * not a map anybody can play and says so here rather than at the first round.
85 */
86 export function levelOf(name: string, grid: Grid<Cell>): Level {
87 const halfX = (grid.width / 2) * CELL;
88 const halfY = (grid.height / 2) * CELL;
89 const floor: Box = { min: [-halfX, -halfY, -32], max: [halfX, halfY, 0], mat: MAT.FLOOR };
90 const merge = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] =>
91 g.mergeSolid(grid, match, z0, z1, mat);
92 const spawns = { t: cellsOf(grid, "t"), ct: cellsOf(grid, "c") };
93 for (const [side, list] of Object.entries(spawns))
94 if (list.length === 0) throw new Error(`no cell of this map is a ${side} spawn`);
95 return {
96 name,
97 grid,
98 passable,
99 wallHeight: WALL_HEIGHT,
100 materials: MAT,
101 // Everything solid, in one list: the renderer draws it and bullets stop in it.
102 boxes: [
103 floor,
104 siteSlab(grid, "A"),
105 siteSlab(grid, "B"),
106 ...merge((c) => c === "#", 0, WALL_HEIGHT, MAT.WALL),
107 ...merge((c) => c === "x", 0, 64, MAT.CRATE),
108 ...merge((c) => c === "X", 0, 128, MAT.CRATE),
109 ],
110 spawns,
111 sites: { A: centre(cellsOf(grid, "A")), B: centre(cellsOf(grid, "B")) },
112 siteCells: { A: "A", B: "B" },
113 };
114 }
src/client/generate.test.ts added +228
@@ -0,0 +1,228 @@
1 /**
2 * The generator, held to the two things it promises: that a seed is a map, the
3 * same map on every machine, and that a map it hands back can be played — one
4 * place, two sites, ten spawns, and a walk from each spawn to each site that A*
5 * finds rather than a flood fill hopes for.
6 *
7 * Nothing here reads the generator's own gate and calls it a day: `flaws` is
8 * what the generator itself refuses on, so the checks below path, flood and
9 * count the level again from the outside. The last two tests are the point of
10 * the whole file — a round of the real game, with the real bots, on a map
11 * nobody wrote, played headlessly and pinned to a digest.
12 */
13
14 import { expect, test } from "bun:test";
15 import { botThink } from "./bots.ts";
16 import { MAT, type Cell } from "./cells.ts";
17 import { flaws, generateGrid, generateLevel, nameOf, page, SIZE } from "./generate.ts";
18 import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
19 import { SHORT } from "./match.ts";
20 import { digest, withoutEntropy } from "./replay.ts";
21 import { applyInput, createWorld, type Player } from "./world.ts";
22 import { region, type Vec3 } from "../engine/index.ts";
23
24 /** How long a walk is, in Source units: the sum of the steps A* handed back. */
25 const walkLength = (path: Vec3[], from: Vec3): number => {
26 let at = from, total = 0;
27 for (const step of path) {
28 total += Math.hypot(step[0] - at[0], step[1] - at[1]);
29 at = step;
30 }
31 return total;
32 };
33
34 const letters = (level: Level): Cell[] => level.grid.cells as Cell[];
35
36 test("a seed is a map, and the same seed is the same map for ever", () => {
37 expect(page(generateGrid(7))).toEqual(page(generateGrid(7)));
38 expect(page(generateGrid(7))).not.toEqual(page(generateGrid(8)));
39 // Two levels from one seed agree about every point a round is played at.
40 const a = generateLevel(7), b = generateLevel(7);
41 expect(a.name).toBe("gen_7");
42 expect(nameOf(1234)).toBe("gen_ya");
43 expect(a.boxes).toEqual(b.boxes);
44 expect(a.spawns).toEqual(b.spawns);
45 expect(a.sites).toEqual(b.sites);
46 });
47
48 test("a map is written on a page of letters, one string a row", () => {
49 const grid = generateGrid(3);
50 const rows = page(grid);
51 expect(rows.length).toBe(SIZE);
52 for (const row of rows) expect(row.length).toBe(SIZE);
53 expect(rows[0]).toBe("#".repeat(SIZE)); // the border is rock, all the way round
54 expect(rows[SIZE - 1]).toBe("#".repeat(SIZE));
55 for (const row of rows) expect(row[0]).toBe("#");
56 expect(rows.join("")).toBe(grid.cells.join(""));
57 });
58
59 test("every map is the other side's map turned half a circle", () => {
60 // The whole of fairness: the cell at (col, row) is the cell at the far end of
61 // the diagonal through the middle, with T for CT and A for B.
62 const mate: Record<string, string> = { "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t" };
63 for (let seed = 1; seed <= 40; seed++) {
64 const grid = generateGrid(seed);
65 for (let row = 0; row < grid.height; row++)
66 for (let col = 0; col < grid.width; col++) {
67 const here = grid.cells[row * grid.width + col] as string;
68 const there = grid.cells[(grid.height - 1 - row) * grid.width + (grid.width - 1 - col)] as string;
69 if (mate[here] !== there) throw new Error(`seed ${seed}: (${col}, ${row}) is ${here} but its mate is ${there}`);
70 }
71 }
72 });
73
74 test("the walk to your site is the walk to theirs, step for step", () => {
75 // Turning the map half a circle turns the T spawn into the CT spawn and A
76 // into B, so the two walks are the same walk. Reading order runs north to
77 // south, so the mate of the first CT spawn cell is the *last* T spawn cell.
78 for (const seed of [2, 13, 44, 101, 777]) {
79 const level = generateLevel(seed);
80 // The middle of the two sites are exact opposites, because a site is an odd
81 // number of cells on both sides and the middle of an odd run is a cell.
82 expect(level.sites.A[0]).toBe(-level.sites.B[0]);
83 expect(level.sites.A[1]).toBe(-level.sites.B[1]);
84 const ct = level.spawns.ct[0] as Vec3;
85 const t = level.spawns.t[level.spawns.t.length - 1] as Vec3;
86 const ctToA = walkLength(findPath(level, ct, level.sites.A), ct);
87 const tToB = walkLength(findPath(level, t, level.sites.B), t);
88 const ctToB = walkLength(findPath(level, ct, level.sites.B), ct);
89 const tToA = walkLength(findPath(level, t, level.sites.A), t);
90 expect(ctToA).toBeGreaterThan(0);
91 expect(ctToA).toBeCloseTo(tToB, 6);
92 expect(ctToB).toBeCloseTo(tToA, 6);
93 }
94 });
95
96 test("two hundred seeds all come out playable, first time", () => {
97 // The retry loop in `generateLevel` is insurance; this is the claim that it
98 // is never spent. Every level here is the seed's first attempt.
99 for (let seed = 0; seed < 200; seed++) {
100 const level = generateLevel(seed);
101 expect(flaws(level)).toEqual([]);
102
103 // Ten players have somewhere of their own to stand, and it is floor.
104 expect(level.spawns.t.length).toBeGreaterThanOrEqual(8);
105 expect(level.spawns.ct.length).toBe(level.spawns.t.length);
106 for (const side of ["t", "ct"] as const)
107 for (const spot of level.spawns[side])
108 expect(walkable(level, colOf(level, spot[0]), rowOf(level, spot[1]))).toBe(true);
109
110 // The middle of each site is on that site and can be planted on.
111 expect(siteAt(level, level.sites.A)).toBe("A");
112 expect(siteAt(level, level.sites.B)).toBe("B");
113
114 // One place: everything walkable can be walked to from a spawn.
115 const first = level.spawns.t[0] as Vec3;
116 const reached = region(level.grid, level.passable, colOf(level, first[0]), rowOf(level, first[1]));
117 const stranded = letters(level).filter((c, i) => level.passable(c) && reached[i] === 0);
118 expect(stranded).toEqual([]);
119
120 // A map with room to move. Measured over a thousand seeds, the tightest
121 // map is 286 cells of floor and the most open is 684, of 1,600.
122 expect(openCells(level).length).toBeGreaterThan(SIZE * SIZE / 6);
123 }
124 });
125
126 test("no generated map leaks outside its own grid, or paints a surface this game has not got", () => {
127 const known = new Set<number>(Object.values(MAT));
128 for (let seed = 500; seed < 540; seed++) {
129 const level = generateLevel(seed);
130 const edge = [(level.grid.width / 2) * level.grid.cell, (level.grid.height / 2) * level.grid.cell];
131 for (const box of level.boxes) {
132 for (const axis of [0, 1]) {
133 expect(box.min[axis as 0 | 1]).toBeGreaterThanOrEqual(-(edge[axis] as number));
134 expect(box.max[axis as 0 | 1]).toBeLessThanOrEqual(edge[axis] as number);
135 }
136 // No new material means no new atlas layer, which is a bundle nobody pays for.
137 expect(known.has(box.mat)).toBe(true);
138 }
139 }
140 });
141
142 test("the gate bites: a map with its site walled off, and one with nowhere to spawn", () => {
143 const level = generateLevel(21);
144 expect(flaws(level)).toEqual([]);
145
146 // Fill the ring around bombsite A with rock and nobody can reach it. The
147 // cells inside are still walkable and now cannot be walked to, so the gate
148 // says both things.
149 const shut = { ...level, grid: { ...level.grid, cells: [...level.grid.cells] } } as Level;
150 const { width, height } = shut.grid;
151 for (let row = 0; row < height; row++)
152 for (let col = 0; col < width; col++)
153 if (shut.grid.cells[row * width + col] === "A")
154 for (let dr = -1; dr <= 1; dr++)
155 for (let dc = -1; dc <= 1; dc++) {
156 const c = col + dc, r = row + dr;
157 if (c < 0 || r < 0 || c >= width || r >= height) continue;
158 if (shut.grid.cells[r * width + c] !== "A") shut.grid.cells[r * width + c] = "#";
159 }
160 const complaints = flaws(shut).join("\n");
161 expect(complaints).toContain("no path from the t spawn to bombsite A");
162 expect(complaints).toContain("no path from the ct spawn to bombsite A");
163 expect(complaints).toContain("cannot be walked to");
164
165 // And a level with one spawn cell a side is not a level for ten players.
166 const thin = { ...level, spawns: { t: [level.spawns.t[0] as Vec3], ct: [level.spawns.ct[0] as Vec3] } };
167 expect(flaws(thin).length).toBe(2);
168 expect(flaws(thin)[0]).toContain("a side is five players");
169 });
170
171 test("a map arrives in a couple of milliseconds, which is a menu press", () => {
172 for (let seed = 0; seed < 20; seed++) generateLevel(seed); // warm
173 const started = performance.now();
174 const runs = 40;
175 for (let seed = 900; seed < 900 + runs; seed++) generateLevel(seed);
176 const each = (performance.now() - started) / runs;
177 // Measured at 2.1 ms a map on this machine: the carving, the crates that are
178 // checked for cutting the map in two, the meshing and four A* searches.
179 // Twenty is the ceiling a slow CI box has to beat, not the number to read.
180 expect(each).toBeLessThan(20);
181 });
182
183 // --- and the game played on it --------------------------------------------
184
185 test("a bot walks a map nobody wrote, on that map's own cells", () => {
186 const level = generateLevel(64);
187 const world = createWorld(level, SHORT, "me", "ct", 1, 5);
188 expect(world.level).toBe(level);
189 world.match.phase = "live";
190 world.me.alive = false;
191 const bot = world.players.find((p) => p.bot) as Player;
192 const from: [number, number] = [bot.pos[0], bot.pos[1]];
193 for (let tick = 0; tick < 64 * 3; tick++) {
194 world.time += 1 / 64;
195 applyInput(world, bot, botThink(world, bot, 1 / 64), 1 / 64);
196 }
197 // It went somewhere, along cells of this map, and this map's walls held it in.
198 expect(Math.hypot(bot.pos[0] - from[0], bot.pos[1] - from[1])).toBeGreaterThan(200);
199 const path = (bot.brain as { path: Vec3[] }).path;
200 expect(path.length).toBeGreaterThan(1);
201 for (const step of path)
202 expect(walkable(level, colOf(level, step[0]), rowOf(level, step[1]))).toBe(true);
203 expect(Math.abs(bot.pos[0])).toBeLessThan((level.grid.width / 2) * level.grid.cell);
204 expect(Math.abs(bot.pos[1])).toBeLessThan((level.grid.height / 2) * level.grid.cell);
205 });
206
207 /**
208 * Ten seconds of a five-a-side round on `gen_5`, seed 11, through the same
209 * script `replay.ts` pins de_forge with. Computed on this branch, where the
210 * generator was written.
211 *
212 * It pins two things at once: that the simulation is deterministic on a
213 * generated map, and that this seed still makes *this* map. Tuning the
214 * generator — a room more, a crate fewer — moves it, and that is the point:
215 * paste the new digest in the pull that tuned it and say which seed's map
216 * changed shape. A digest that moves for any other reason is a bug.
217 */
218 const GENERATED_ROUND = "95aedb752f57a576d996a90212f973a4233a2906c2625c12a89b3b2222ef6f2e";
219
220 test("a seeded round on a generated map plays exactly as it played", () => {
221 const level = generateLevel(5);
222 const run = withoutEntropy(() => digest(createWorld(level, SHORT, "me", "ct", 5, 11), 10 * 64));
223 expect(run.digest).toBe(GENERATED_ROUND);
224 // Nobody fell through the floor or stood in a wall for ten seconds.
225 for (const p of createWorld(level, SHORT, "me", "ct", 5, 11).players)
226 expect(walkable(level, colOf(level, p.pos[0]), rowOf(level, p.pos[1]))).toBe(true);
227 expect(worldOf(level, 0, 0)[2]).toBe(0);
228 });
src/client/generate.ts added +325
@@ -0,0 +1,325 @@
1 /**
2 * A map from a number. Rooms, corridors, two bombsites, two spawns and cover,
3 * carved out of solid rock into the alphabet `cells.ts` reads — so a generated
4 * map is one more value of `Level` and nothing in the simulation, the renderer
5 * or the bots learns a thing to play it.
6 *
7 * Two promises hold the whole file up.
8 *
9 * **It is fair.** Everything is stamped twice: a cell at (col, row) and the cell
10 * at (width - 1 - col, height - 1 - row), with `t` becoming `c` and `A`
11 * becoming `B` on the way. The map is therefore its own opponent's map turned
12 * half a circle, so the walk from T spawn to A is the walk from CT spawn to B,
13 * step for step, and no seed can hand one side a shorter round. A test paths
14 * both and compares the lengths.
15 *
16 * **It is playable.** Every room hangs off a spanning tree of corridors that
17 * reaches the middle, which is the one room both halves share, so the map is
18 * one place by construction rather than by luck; `region` then rubs out
19 * anything the tree missed, a crate that would cut the map in two is taken back
20 * off, and `flaws` reads the finished level for the things a round needs —
21 * spawns to stand on, sites to plant on, and a path from each spawn to each
22 * site. A seed whose map has a flaw is not shipped: the next seed is derived
23 * from it and the work is done again.
24 *
25 * Determinism is the point: a seed is a map, on every machine, for ever. The
26 * only source of randomness is the engine's `rng`, which is the same generator
27 * the rounds themselves run on.
28 */
29
30 import { region, rng, type Grid, type Vec3 } from "../engine/index.ts";
31 import { CELL, levelOf, type Cell } from "./cells.ts";
32 import { colOf, findPath, rowOf, type Level } from "./level.ts";
33
34 /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */
35 export const SIZE = 40;
36
37 /** How many attempts a seed gets before the generator gives up on it. */
38 const ATTEMPTS = 8;
39
40 /** [col0, row0, col1, row1], inclusive both ends, like de_forge's areas. */
41 type Rect = { c0: number; r0: number; c1: number; r1: number };
42
43 type Point = { col: number; row: number };
44
45 /** What a letter becomes when it is stamped on the other side of the map. */
46 const MATE: Record<Cell, Cell> = {
47 "#": "#", ".": ".", x: "x", X: "X", A: "B", B: "A", t: "c", c: "t",
48 };
49
50 /**
51 * A seed out of whatever text a page can hand over — the match id, usually,
52 * which is a uuid the browser made. FNV-1a, thirty-two bits: a hash rather than
53 * a clock, so a page that wants a map every match needs no source of numbers of
54 * its own and a test can hand in a word and get the same map every time.
55 */
56 export function seedFrom(text: string): number {
57 let hash = 0x811c9dc5;
58 for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619);
59 return hash >>> 0;
60 }
61
62 /** The name a seed's map answers to: `gen_1z3f`, short enough for a menu. */
63 export const nameOf = (seed: number): string => `gen_${(seed >>> 0).toString(36)}`;
64
65 /** The page of letters a grid is, one string a row: what a test or a terminal reads. */
66 export const page = (grid: Grid<Cell>): string[] =>
67 Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join(""));
68
69 // --- carving --------------------------------------------------------------
70
71 /**
72 * One map under construction: the cells, and the only way anything is written
73 * into them. Every write is two writes — the cell, and the cell half a circle
74 * away with its letter turned round — so symmetry is not something to remember
75 * at the end but the only thing this page can do.
76 */
77 function sheet(size: number): {
78 cells: Cell[];
79 set: (col: number, row: number, cell: Cell) => void;
80 fill: (rect: Rect, cell: Cell) => void;
81 } {
82 const cells: Cell[] = new Array(size * size).fill("#");
83 // The border is rock and stays rock: nothing is ever carved into the outer ring.
84 const inside = (col: number, row: number): boolean =>
85 col >= 1 && row >= 1 && col <= size - 2 && row <= size - 2;
86 const set = (col: number, row: number, cell: Cell): void => {
87 if (!inside(col, row)) return;
88 cells[row * size + col] = cell;
89 cells[(size - 1 - row) * size + (size - 1 - col)] = MATE[cell];
90 };
91 const fill = (rect: Rect, cell: Cell): void => {
92 for (let r = rect.r0; r <= rect.r1; r++)
93 for (let c = rect.c0; c <= rect.c1; c++) set(c, r, cell);
94 };
95 return { cells, set, fill };
96 }
97
98 const centreOf = (rect: Rect): Point => ({
99 col: Math.floor((rect.c0 + rect.c1) / 2),
100 row: Math.floor((rect.r0 + rect.r1) / 2),
101 });
102
103 /**
104 * An L from one room to another, two or three cells wide: along the columns to
105 * the elbow, then down the rows, both runs carved as rectangles so the corner
106 * they share is open and nobody has to cut it. Which run goes first is the
107 * seed's business, which is what keeps a map from being all right angles the
108 * same way round.
109 */
110 function corridor(
111 paint: ReturnType<typeof sheet>,
112 a: Point,
113 b: Point,
114 width: number,
115 columnsFirst: boolean,
116 ): void {
117 const elbow = columnsFirst ? { col: b.col, row: a.row } : { col: a.col, row: b.row };
118 const span = (from: Point, to: Point): Rect => ({
119 c0: Math.min(from.col, to.col),
120 r0: Math.min(from.row, to.row),
121 c1: Math.max(from.col, to.col) + (from.row === to.row ? 0 : width - 1),
122 r1: Math.max(from.row, to.row) + (from.col === to.col ? 0 : width - 1),
123 });
124 paint.fill(span(a, elbow), ".");
125 paint.fill(span(elbow, b), ".");
126 }
127
128 /**
129 * The corridors: a spanning tree over the rooms, nearest room first, grown from
130 * the middle — so every room reaches the middle, and the middle is the room
131 * both halves of the map share. Then one or two edges more, chosen at random,
132 * because a map with exactly one route between two places is a queue rather
133 * than a fight.
134 */
135 function connect(paint: ReturnType<typeof sheet>, rooms: Point[], random: () => number): void {
136 const joined = [0];
137 const waiting = rooms.map((_, i) => i).slice(1);
138 const width = (): number => (random() < 0.45 ? 3 : 2);
139 while (waiting.length > 0) {
140 let best = 0, from = 0, far = Infinity;
141 for (let w = 0; w < waiting.length; w++)
142 for (const j of joined) {
143 const p = rooms[waiting[w] as number] as Point, q = rooms[j] as Point;
144 const d = Math.hypot(p.col - q.col, p.row - q.row);
145 if (d < far) { far = d; best = w; from = j; }
146 }
147 const next = waiting.splice(best, 1)[0] as number;
148 corridor(paint, rooms[from] as Point, rooms[next] as Point, width(), random() < 0.5);
149 joined.push(next);
150 }
151 const extra = 1 + Math.floor(random() * 2);
152 for (let i = 0; i < extra && rooms.length > 2; i++) {
153 const a = Math.floor(random() * rooms.length);
154 const b = (a + 1 + Math.floor(random() * (rooms.length - 1))) % rooms.length;
155 corridor(paint, rooms[a] as Point, rooms[b] as Point, width(), random() < 0.5);
156 }
157 }
158
159 // --- the map --------------------------------------------------------------
160
161 /**
162 * One attempt at a layout, as a grid of letters. Everything is decided in the
163 * northern half and stamped into the southern half turned half a circle: the CT
164 * spawn is the T spawn, the A site is the B site, and the middle room is its
165 * own reflection, which is where the two halves meet.
166 */
167 export function generateGrid(seed: number, size = SIZE): Grid<Cell> {
168 const random = rng(seed);
169 const pick = (from: number, to: number): number => from + Math.floor(random() * (to - from + 1));
170 const paint = sheet(size);
171 const middle = size / 2;
172
173 // The CT spawn: eight by four, across the top, near the middle column.
174 const spawnC0 = pick(middle - 6, middle - 2);
175 const spawn: Rect = { c0: spawnC0, r0: 2, c1: spawnC0 + 7, r1: 5 };
176
177 // The A site: seven wide, five or seven deep, out on one flank — clear of the
178 // spawn's columns and wholly in the northern half, so its mate is the B site
179 // on the other flank and the two can never touch. Both sides of it are odd
180 // on purpose: the middle of an odd run of cells is a cell, so the point the
181 // bomb is planted at is the centre of a cell rather than the seam between
182 // two, and the two sites' middles are exact opposites of each other. A seam
183 // rounds the wrong way on one side of the map and hands that side a shorter
184 // walk, which is the one thing this file is not allowed to do.
185 const siteC0 = random() < 0.5 ? pick(size - 12, size - 9) : pick(2, 6);
186 const siteR0 = pick(3, 12);
187 const site: Rect = { c0: siteC0, r0: siteR0, c1: siteC0 + 6, r1: siteR0 + (random() < 0.5 ? 4 : 6) };
188
189 // The middle: the one room that is its own mate, and the seam of the map.
190 const midC0 = middle - pick(3, 5), midR0 = middle - pick(2, 4);
191 const mid: Rect = { c0: midC0, r0: midR0, c1: size - 1 - midC0, r1: size - 1 - midR0 };
192
193 // Three or four halls in the northern half, wherever they land: they may cross
194 // anything, because the letters go on last.
195 const halls: Rect[] = [];
196 for (let i = 0, want = pick(3, 4); i < want; i++) {
197 const w = pick(4, 7), h = pick(3, 5);
198 const c0 = pick(2, size - 3 - w), r0 = pick(2, middle - 3 - h);
199 halls.push({ c0, r0, c1: c0 + w, r1: r0 + h });
200 }
201
202 for (const rect of [spawn, site, mid, ...halls]) paint.fill(rect, ".");
203 connect(paint, [centreOf(mid), centreOf(spawn), centreOf(site), ...halls.map(centreOf)], random);
204
205 // The letters last, so a corridor that crossed a spawn is spawn floor rather
206 // than a hole in it.
207 paint.fill(site, "A");
208 paint.fill(spawn, "c");
209
210 // Anything the tree missed is rock again: a courtyard nobody can reach is a
211 // place the renderer draws, the bots path into and the round never visits.
212 // Rubbing it out cell by cell keeps the symmetry it was carved with, because
213 // what one spawn can reach is what the other can: the two are the same room
214 // turned half a circle, and the middle joins them.
215 const grid: Grid<Cell> = { width: size, height: size, cell: CELL, cells: paint.cells };
216 const home = centreOf(spawn);
217 const reached = region(grid, (c) => c !== "#", home.col, home.row);
218 for (let i = 0; i < grid.cells.length; i++)
219 if (reached[i] === 0 && grid.cells[i] !== "#") grid.cells[i] = "#";
220
221 crates(paint, grid, centreOf(site), random);
222 return grid;
223 }
224
225 /**
226 * Cover, in pairs: a crate and the crate half a circle away, lower-case to jump
227 * on and upper-case to hide behind. A crate goes on floor in a roomy place —
228 * every orthogonal neighbour open and six of the eight — never on a spawn and
229 * never on a bombsite, whose cells decide where the bomb is planted. If the
230 * pair cuts the map in two it comes back off, which is why a generated map can
231 * be crated at all: a plug in a two-wide corridor is a round nobody can finish.
232 */
233 function crates(
234 paint: ReturnType<typeof sheet>,
235 grid: Grid<Cell>,
236 siteMiddle: Point,
237 random: () => number,
238 ): void {
239 const { width, cells } = grid;
240 const open = (col: number, row: number): boolean =>
241 col >= 0 && row >= 0 && col < width && row < grid.height && cells[row * width + col] !== "#";
242 const walkable = (c: Cell): boolean => c !== "#" && c !== "x" && c !== "X";
243 const roomy = (col: number, row: number): boolean => {
244 if (!open(col, row)) return false;
245 if (cells[row * width + col] !== ".") return false;
246 if (!open(col - 1, row) || !open(col + 1, row) || !open(col, row - 1) || !open(col, row + 1)) return false;
247 let around = 0;
248 for (let dr = -1; dr <= 1; dr++)
249 for (let dc = -1; dc <= 1; dc++) if ((dc !== 0 || dr !== 0) && open(col + dc, row + dr)) around++;
250 return around >= 6;
251 };
252 const spots: Point[] = [];
253 for (let row = 1; row < grid.height / 2; row++)
254 for (let col = 1; col < width - 1; col++) if (roomy(col, row)) spots.push({ col, row });
255 let want = 6 + Math.floor(random() * 5);
256 while (want > 0 && spots.length > 0) {
257 const spot = spots.splice(Math.floor(random() * spots.length), 1)[0] as Point;
258 if (!roomy(spot.col, spot.row)) continue;
259 const before = cells[spot.row * width + spot.col] as Cell;
260 const mate = cells[(grid.height - 1 - spot.row) * width + (width - 1 - spot.col)] as Cell;
261 paint.set(spot.col, spot.row, random() < 0.7 ? "x" : "X");
262 const still = region(grid, walkable, siteMiddle.col, siteMiddle.row);
263 let cut = false;
264 for (let i = 0; i < cells.length && !cut; i++)
265 if (still[i] === 0 && walkable(cells[i] as Cell)) cut = true;
266 if (cut) {
267 cells[spot.row * width + spot.col] = before;
268 cells[(grid.height - 1 - spot.row) * width + (width - 1 - spot.col)] = mate;
269 continue;
270 }
271 want--;
272 }
273 }
274
275 // --- the gate -------------------------------------------------------------
276
277 /**
278 * What is wrong with this map, in sentences. A round needs somewhere for ten
279 * players to stand, two sites big enough to plant on, and a way from each spawn
280 * to each site that A* can actually find — the bots walk it, so it is asked of
281 * A* rather than of a flood fill. An empty list is a map that can be played.
282 */
283 export function flaws(level: Level): string[] {
284 const bad: string[] = [];
285 for (const side of ["t", "ct"] as const)
286 if (level.spawns[side].length < 8)
287 bad.push(`${level.name}: ${level.spawns[side].length} cells to spawn ${side} on, and a side is five players`);
288 for (const letter of ["A", "B"] as const) {
289 const paved = level.grid.cells.filter((c) => c === letter).length;
290 if (paved < 10) bad.push(`${level.name}: bombsite ${letter} is ${paved} cells, too small to fight over`);
291 }
292 for (const side of ["t", "ct"] as const)
293 for (const letter of ["A", "B"] as const) {
294 const from = level.spawns[side][0] as Vec3;
295 if (findPath(level, from, level.sites[letter]).length === 0)
296 bad.push(`${level.name}: no path from the ${side} spawn to bombsite ${letter}`);
297 }
298 const first = level.spawns.t[0] as Vec3;
299 const reached = region(level.grid, level.passable, colOf(level, first[0]), rowOf(level, first[1]));
300 let stranded = 0;
301 for (let i = 0; i < level.grid.cells.length; i++)
302 if (reached[i] === 0 && level.passable(level.grid.cells[i] as Cell)) stranded++;
303 if (stranded > 0) bad.push(`${level.name}: ${stranded} cells are walkable and cannot be walked to`);
304 return bad;
305 }
306
307 /**
308 * A map from a seed, whole and playable. The seed is tried, and a layout with a
309 * flaw in it is not shipped: the seed is mixed into another one and the work is
310 * done again, up to eight times. In 2,000 seeds measured none needed a second
311 * attempt, so the loop is insurance rather than a plan, and a seed that somehow
312 * spends all eight says so loudly instead of handing back a map nobody can
313 * finish a round on.
314 */
315 export function generateLevel(seed: number, size = SIZE): Level {
316 let at = seed >>> 0;
317 let last: string[] = [];
318 for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
319 const level = levelOf(nameOf(seed), generateGrid(at, size));
320 last = flaws(level);
321 if (last.length === 0) return level;
322 at = (Math.imul(at, 1664525) + 1013904223) >>> 0;
323 }
324 throw new Error(`no map came out of seed ${seed >>> 0} in ${ATTEMPTS} attempts: ${last.join("; ")}`);
325 }
src/client/main.test.ts +31
@@ -17,6 +17,8 @@
1717 */
1818
1919 import { expect, test } from "bun:test";
20 import { nameOf, seedFrom } from "./generate.ts";
21 import type { Level } from "./level.ts";
2022 import { boot, gameMeshes, type Frame } from "./main.ts";
2123 import { LAYERS, materialTextures } from "./materials.ts";
2224 import { WEAPONS } from "./weapons.ts";
@@ -438,3 +440,32 @@ test("every mesh name a scene of this game can place is handed in at constructio
438440 expect(name).toMatch(/^[a-z]+$/);
439441 }
440442 });
443
444 test("the map field chooses the map, and a generated one costs one mesh", () => {
445 const stage = painted(start(fakeStage()));
446 // de_forge is what the meshes were built from, so nothing is swapped for it.
447 stage.by("play").fire("click");
448 expect(worldOf(stage)?.level.name).toBe("de_forge");
449 expect(stage.replaced).toEqual([]);
450 expect(stage.by("onmap").textContent).toBe("Playing de_forge.");
451
452 // Generated: the level is carved from this match's id, and the renderer is
453 // handed that level's geometry under the name it already holds — one mesh,
454 // not a second renderer.
455 stage.by("quit").fire("click");
456 (stage.by("map") as unknown as { value: string }).value = "generated";
457 stage.by("play").fire("click");
458 const level = worldOf(stage)?.level;
459 expect(level?.name).toBe(nameOf(seedFrom(stage.ids[stage.ids.length - 1] as string)));
460 expect(stage.replaced.map((r) => r.name)).toEqual(["level"]);
461 expect(stage.replaced[0]?.mesh.index.length).toBe(gameMeshes(level as Level).level?.index.length);
462 expect(stage.by("onmap").textContent).toBe(`Playing ${level?.name}.`);
463
464 // And a value nobody rendered is de_forge, like an unknown anything here.
465 stage.by("quit").fire("click");
466 (stage.by("map") as unknown as { value: string }).value = "de_moon";
467 stage.by("play").fire("click");
468 expect(worldOf(stage)?.level.name).toBe("de_forge");
469 // The geometry went back to de_forge's with it: two swaps, both of the level.
470 expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]);
471 });
src/client/main.ts +24 −3
@@ -28,6 +28,7 @@ import {
2828 attachButtons, clamp, createControls, createFullscreen, createLoop, createRenderer, painter,
2929 type Button, type Camera, type Listens, type Mesh,
3030 } from "../engine/index.ts";
31 import { generateLevel, seedFrom } from "./generate.ts";
3132 import { DE_FORGE } from "./map.ts";
3233 import type { Level } from "./level.ts";
3334 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
@@ -105,8 +106,15 @@ export type Store = {
105106 /** The two things this file reads off a response to POST /api/match. */
106107 export type Reply = { readonly ok: boolean; text(): Promise<string> };
107108
108 /** All this frame asks of a renderer: one draw call, of one scene. */
109 export type Draws = { draw(scene: Scene, dt: number): void };
109 /**
110 * All this frame asks of a renderer: one draw call of one scene, and one mesh
111 * swapped under its own name — which is what a match on another map costs,
112 * because the geometry is the only thing about the renderer that changes.
113 */
114 export type Draws = {
115 draw(scene: Scene, dt: number): void;
116 replace(name: string, mesh: Mesh): void;
117 };
110118
111119 /**
112120 * What a browser that can draw hands back: the renderer, built from the atlas
@@ -161,6 +169,8 @@ export function boot(host: Host): Frame {
161169 const lengthField = by<Field>("length");
162170 const sideField = by<Field>("side");
163171 const sensField = by<Field>("sens");
172 const mapField = by<Field>("map");
173 const onMap = by("onmap");
164174 const prep = by("prep");
165175 const play = by<Panel & { disabled: boolean }>("play");
166176
@@ -218,6 +228,9 @@ export function boot(host: Host): Frame {
218228 const frameScene = createScene();
219229 const camera: Camera = { pos: [0, 0, 0], yaw: 0, pitch: 0, fov: 90 };
220230 let matchId = "";
231 // The map the renderer is holding the geometry of. It starts as the one the
232 // meshes were built from, and a match on another map swaps that one mesh.
233 let shown: Level = DE_FORGE;
221234 let running = false;
222235 let lastSound = 0;
223236 let frameAverage = 16.7;
@@ -323,8 +336,16 @@ export function boot(host: Host): Frame {
323336 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
324337 const team = sideField.value === "t" ? "t" : "ct";
325338 const name = nameField.value.trim().slice(0, 24) || "you";
326 world = createWorld(DE_FORGE, rules, name, team, 5);
327339 matchId = host.randomUUID();
340 // A map a person chose: de_forge, or one carved out of this match's id. An
341 // unknown value in the field is de_forge, like an unknown anything here.
342 const level = mapField.value === "generated" ? generateLevel(seedFrom(matchId)) : DE_FORGE;
343 if (level !== shown) {
344 renderer.replace("level", meshFor("level", level));
345 shown = level;
346 }
347 onMap.textContent = `Playing ${level.name}.`;
348 world = createWorld(level, rules, name, team, 5);
328349 posted = -1;
329350 loop.reset();
330351 controls.sensitivity = clamp(Number(sensField.value) || 2.2, 0.2, 12);
src/client/map.ts +15 −85
@@ -7,11 +7,14 @@
77 * Layout is dust-shaped and original: two sites, a mid, a long right-hand
88 * corridor and a left-hand tunnel, T at the bottom of the grid, CT at the top.
99 *
10 * This file is one map and nothing else. What a level *is* — the grid, the
11 * boxes, the spawns, the sites and the questions asked of them — is
12 * `src/client/level.ts`, and `DE_FORGE` below is one value of that type, built
13 * once here and handed to `createWorld`. Nobody imports this map to find out
14 * where they are standing; they read the level off the world they are in.
10 * This file is one map and nothing else: the areas, the crates and nothing
11 * about what a letter means. What the letters are and what a page of them
12 * becomes is `src/client/cells.ts`, which `generate.ts` writes its maps in too;
13 * what a level *is* — the grid, the boxes, the spawns, the sites and the
14 * questions asked of them — is `src/client/level.ts`, and `DE_FORGE` below is
15 * one value of that type, built once here and handed to `createWorld`. Nobody
16 * imports this map to find out where they are standing; they read the level off
17 * the world they are in.
1518 *
1619 * Everything in here is Counter-Strike. The grid itself — the cell arithmetic,
1720 * the greedy meshing and the A* — is `src/engine/grid.ts`, which knows nothing
@@ -19,15 +22,12 @@
1922 */
2023
2124 import * as g from "../engine/index.ts";
22 import type { Box, Vec3 } from "../engine/index.ts";
25 import { CELL, levelOf, type Cell } from "./cells.ts";
2326 import type { Level } from "./level.ts";
2427
25 export const CELL = 128;
28 /** De_forge is 44 cells on a side: 5,632 Source units, a little under dust's. */
2629 export const GRID_W = 44;
2730 export const GRID_H = 44;
28 export const WALL_HEIGHT = 384;
29
30 export type Cell = "#" | "." | "x" | "X" | "A" | "B" | "t" | "c";
3131
3232 /** [col0, row0, col1, row1] inclusive, and what the cells become. */
3333 type Area = { name: string; rect: [number, number, number, number]; fill: Cell };
@@ -73,81 +73,11 @@ function build(): g.Grid<Cell> {
7373 return { width: GRID_W, height: GRID_H, cell: CELL, cells };
7474 }
7575
76 const grid = build();
77
78 /** Off the edge of de_forge is rock, which is what the border is made of anyway. */
79 const cellAt = (col: number, row: number): Cell => g.cellAt(grid, col, row) ?? "#";
76 const grid: g.Grid<Cell> = build();
8077
8178 /**
82 * Solid to a walking bot: walls and both kinds of crate. This predicate is the
83 * whole of what the engine's search knows about de_forge, and it is the one
84 * thing the level carries that is a function rather than a number.
79 * de_forge, whole: the map this game was built on, as one value of `Level`.
80 * The letters are `cells.ts`'s and so is the reading of them — this file is the
81 * page they are written on and nothing else.
8582 */
86 const passable = (c: string): boolean => c !== "#" && c !== "x" && c !== "X";
87
88 const worldOf = (col: number, row: number, z = 0): Vec3 => g.worldOf(grid, col, row, z);
89
90 // --- geometry -------------------------------------------------------------
91
92 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
93
94 const mergeSolid = (match: (c: Cell) => boolean, z0: number, z1: number, mat: number): Box[] =>
95 g.mergeSolid(grid, match, z0, z1, mat);
96
97 function siteSlab(fill: Cell): Box {
98 let c0 = GRID_W, r0 = GRID_H, c1 = 0, r1 = 0;
99 for (let r = 0; r < GRID_H; r++)
100 for (let c = 0; c < GRID_W; c++)
101 if (cellAt(c, r) === fill) {
102 c0 = Math.min(c0, c); c1 = Math.max(c1, c);
103 r0 = Math.min(r0, r); r1 = Math.max(r1, r);
104 }
105 const a = worldOf(c0, r0), b = worldOf(c1, r1);
106 return {
107 min: [a[0] - CELL / 2, b[1] - CELL / 2, 0],
108 max: [b[0] + CELL / 2, a[1] + CELL / 2, 2],
109 mat: MAT.SITE,
110 };
111 }
112
113 const half = (GRID_W / 2) * CELL;
114 const floor: Box = { min: [-half, -half, -32], max: [half, half, 0], mat: MAT.FLOOR };
115
116 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
117 const boxes: Box[] = [
118 floor,
119 siteSlab("A"),
120 siteSlab("B"),
121 ...mergeSolid((c) => c === "#", 0, WALL_HEIGHT, MAT.WALL),
122 ...mergeSolid((c) => c === "x", 0, 64, MAT.CRATE),
123 ...mergeSolid((c) => c === "X", 0, 128, MAT.CRATE),
124 ];
125
126 // --- places ---------------------------------------------------------------
127
128 function cellsOf(fill: Cell): Vec3[] {
129 const out: Vec3[] = [];
130 for (let r = 0; r < GRID_H; r++)
131 for (let c = 0; c < GRID_W; c++) if (cellAt(c, r) === fill) out.push(worldOf(c, r));
132 return out;
133 }
134
135 function centre(list: Vec3[]): Vec3 {
136 const s = list.reduce((a, p) => [a[0] + p[0], a[1] + p[1], 0] as Vec3, [0, 0, 0] as Vec3);
137 return [s[0] / list.length, s[1] / list.length, 0];
138 }
139
140 const siteCells = { A: cellsOf("A"), B: cellsOf("B") };
141
142 /** de_forge, whole: the one map this game ships, as one value of `Level`. */
143 export const DE_FORGE: Level = {
144 name: "de_forge",
145 grid,
146 passable,
147 wallHeight: WALL_HEIGHT,
148 materials: MAT,
149 boxes,
150 spawns: { t: cellsOf("t"), ct: cellsOf("c") },
151 sites: { A: centre(siteCells.A), B: centre(siteCells.B) },
152 siteCells: { A: "A", B: "B" },
153 };
83 export const DE_FORGE: Level = levelOf("de_forge", grid);
src/client/stage.test-helper.ts +10 −3
@@ -30,6 +30,7 @@
3030
3131 import { fakePage, type FakeCanvas, type FakeDocument, type FakeElement, type FakePaint, type FakeWindow } from "../engine/index.ts";
3232 import type { Audio } from "./audio.ts";
33 import type { Mesh } from "../engine/index.ts";
3334 import type { BuildRenderer, Draws, Host, Stage } from "./main.ts";
3435 import type { Scene } from "./scene.ts";
3536
@@ -62,7 +63,7 @@ const GL_VOIDS = [
6263 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData", "bufferSubData",
6364 "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace", "viewport", "clearColor",
6465 "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f", "uniform3fv",
65 "uniformMatrix4fv", "drawElements", "drawArrays",
66 "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray", "deleteBuffer",
6667 ];
6768
6869 export function glRecorder(): GlRecorder {
@@ -122,6 +123,8 @@ export type FakeStage = {
122123 posts: Post[];
123124 answer(post: Post): Promise<{ ok: boolean; text(): Promise<string> }>;
124125 draws: Drawn[];
126 /** Every mesh swapped under a name after construction: a match on another map. */
127 replaced: { name: string; mesh: Mesh }[];
125128 /**
126129 * The atlas bytes each renderer was built from, newest last. The frame paints
127130 * its atlas a slice at a time and builds the renderer on the last one, so
@@ -150,7 +153,7 @@ export type FakeStage = {
150153 * contract between the page and this file, so `src/server.test.ts` checks the
151154 * served page against this same list rather than a second copy of it.
152155 */
153 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "play", "prep", "resume", "quit", "fps", "full"];
156 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "onmap", "play", "prep", "resume", "quit", "fps", "full"];
154157
155158 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
156159 const HIDDEN = ["pause", "buy", "touch", "full"];
@@ -192,6 +195,7 @@ export function fakeStage(
192195
193196 const posts: Post[] = [];
194197 const draws: Drawn[] = [];
198 const replaced: { name: string; mesh: Mesh }[] = [];
195199 const atlases: Uint8Array[] = [];
196200 const sounds: string[] = [];
197201 const store = new Map<string, string>();
@@ -209,6 +213,9 @@ export function fakeStage(
209213 draw(scene, dt) {
210214 draws.push({ scene, dt });
211215 },
216 replace(name, mesh) {
217 replaced.push({ name, mesh });
218 },
212219 };
213220
214221 const stub: FakeStage = {
@@ -246,7 +253,7 @@ export function fakeStage(
246253 hud,
247254 buttons: page.buttons,
248255 frames: page.frames,
249 posts, draws, atlases, sounds, store, ids,
256 posts, draws, replaced, atlases, sounds, store, ids,
250257 by: page.by,
251258 step: page.step,
252259 lock(on) {
src/engine/GUIDE.md +9 −7
@@ -224,13 +224,15 @@ exactly that reason.
224224 A `Grid` of cells of any type — the cell type is your alphabet, not the
225225 engine's. `inside` and `cellAt` read it, `worldOf`, `colOf` and `rowOf` are the
226226 cell-to-world arithmetic, `mergeSolid` greedily merges runs of matching cells
227 into as few boxes as the shape allows, and `findPath` is A* with no
228 corner-cutting. Which cells are solid and which are walkable arrive as
229 predicates, so the engine never learns what a `"#"` means. The example has no
230 grid — one box is not a map — so read `src/client/level.ts`, which is what a
231 level is to the shooter (a grid, a predicate, boxes, spawns and sites, as one
232 value), and `src/client/map.ts`, which lays de_forge's cell alphabet over this
233 and is one of them.
227 into as few boxes as the shape allows, `findPath` is A* with no
228 corner-cutting, and `region` flags every cell reachable from one you name,
229 taking the same steps the search does — the question a generated layout asks of
230 itself before it hands anybody a map. Which cells are solid and which are
231 walkable arrive as predicates, so the engine never learns what a `"#"` means.
232 The example has no grid — one box is not a map — so read `src/client/level.ts`,
233 which is what a level is to the shooter (a grid, a predicate, boxes, spawns and
234 sites, as one value), and `src/client/map.ts`, which lays de_forge's cell
235 alphabet over this and is one of them.
234236
235237 ### mesh
236238
src/engine/boundary.test.ts +1 −1
@@ -311,7 +311,7 @@ export const SURFACE = [
311311 "length", "length2d", "lerp",
312312 "logitCount", "mat4", "mergeSolid", "modelMatrix", "move", "mul", "multiply", "newActivations",
313313 "nextInstance", "noise", "normalize", "overlay", "pack", "paintAtlas", "painter",
314 "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox",
314 "paramCount", "perspective", "pushLine", "quadMesh", "quantise", "rad", "rayBox", "region",
315315 "resetScene", "rng", "rowOf", "sample", "scale", "segmentLength", "softmax", "stuck", "sub",
316316 "tensorsOf", "traceWorld", "unpack", "update", "v3", "viewMatrix", "visible", "worldOf",
317317 "zeroGrads", "zeroPolicy",
src/engine/grid.test.ts +51 −1
@@ -5,7 +5,9 @@
55 */
66
77 import { expect, test } from "bun:test";
8 import { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf, type Grid } from "./grid.ts";
8 import {
9 cellAt, colOf, findPath, inside, mergeSolid, region, rowOf, worldOf, type Grid,
10 } from "./grid.ts";
911 import type { Vec3 } from "./math.ts";
1012
1113 /** A grid from rows of single characters, `cell` world units on a side. */
@@ -167,3 +169,51 @@ test("a cell is asked about at most once per search", () => {
167169 expect(calls).toBeGreaterThan(0);
168170 expect(calls).toBeLessThanOrEqual(g.width * g.height);
169171 });
172
173 test("a region is every cell one cell can walk to, and the search agrees with it", () => {
174 // col 01234
175 // row0 ..#..
176 // row1 ..#.. the east half is walled off, except nothing joins them
177 // row2 ..#..
178 // row3 ..#..
179 // row4 ..#..
180 const g = draw(["..#..", "..#..", "..#..", "..#..", "..#.."]);
181 const west = region(g, open, 0, 0);
182 expect([...west].filter((f) => f === 1).length).toBe(10);
183 // Flagged is reachable and reachable is flagged: the two answers are the same
184 // answer, which is why a generator may use the cheap one.
185 for (let r = 0; r < g.height; r++)
186 for (let c = 0; c < g.width; c++) {
187 const reached = findPath(g, open, worldOf(g, 0, 0), worldOf(g, c, r)).length > 0;
188 expect(west[r * g.width + c] === 1).toBe(reached || (c === 0 && r === 0));
189 }
190 });
191
192 test("a region takes the diagonal only where the search would", () => {
193 // Two floors touching at a corner: neither can reach the other, so each is a
194 // region of one.
195 const shut = draw([".#", "#."]);
196 expect([...region(shut, open, 0, 0)]).toEqual([1, 0, 0, 0]);
197 // Open one of the two cells beside the seam and they are one place.
198 const half = draw([".#", ".."]);
199 expect([...region(half, open, 0, 0)]).toEqual([1, 0, 1, 1]);
200 });
201
202 test("a region starting nowhere reaches nothing", () => {
203 const g = draw([".#", ".."]);
204 expect([...region(g, open, 1, 0)]).toEqual([0, 0, 0, 0]); // in the rock
205 expect([...region(g, open, -1, 0)]).toEqual([0, 0, 0, 0]); // off the edge
206 expect([...region(g, open, 0, 9)]).toEqual([0, 0, 0, 0]);
207 expect([...region(g, () => false, 0, 0)]).toEqual([0, 0, 0, 0]);
208 });
209
210 test("a region asks about a cell at most once", () => {
211 const g = draw([".....", "..#..", "..#..", "..#..", "....."]);
212 let calls = 0;
213 const counted = (c: string): boolean => {
214 calls++;
215 return open(c);
216 };
217 expect([...region(g, counted, 0, 0)].filter((f) => f === 1).length).toBe(22);
218 expect(calls).toBeLessThanOrEqual(g.width * g.height + 1);
219 });
src/engine/grid.ts +52
@@ -97,6 +97,58 @@ export function mergeSolid<T>(
9797 return out;
9898 }
9999
100 /**
101 * Every cell you can walk to from one you stand in, by the same steps
102 * `findPath` takes: eight ways, and a diagonal only when both cells beside it
103 * are passable too. One flag per cell, `1` for reachable and `0` for not, in
104 * one sweep — the question a map asks of itself ("is all of this one place?"),
105 * which A* answers a route at a time and this answers once.
106 *
107 * A start cell outside the grid, or one that is not passable itself, reaches
108 * nothing and comes back all zeros. `passable` is asked about each cell at most
109 * once, like it is in the search below.
110 */
111 export function region<T>(
112 g: Grid<T>,
113 passable: (cell: T) => boolean,
114 col: number,
115 row: number,
116 ): Uint8Array {
117 const { width, height } = g;
118 const seen = new Uint8Array(width * height);
119 if (!inside(g, col, row) || !passable(g.cells[row * width + col] as T)) return seen;
120 // -1 not asked yet, 0 no, 1 yes.
121 const answer = new Int8Array(seen.length).fill(-1);
122 const passableAt = (c: number, r: number): boolean => {
123 if (c < 0 || r < 0 || c >= width || r >= height) return false;
124 const i = r * width + c;
125 const known = answer[i] as number;
126 if (known >= 0) return known === 1;
127 const ok = passable(g.cells[i] as T) ? 1 : 0;
128 answer[i] = ok;
129 return ok === 1;
130 };
131 const stack: number[] = [row * width + col];
132 seen[row * width + col] = 1;
133 while (stack.length > 0) {
134 const cur = stack.pop() as number;
135 const cc = cur % width, cr = Math.floor(cur / width);
136 for (let dr = -1; dr <= 1; dr++) {
137 for (let dc = -1; dc <= 1; dc++) {
138 if (dc === 0 && dr === 0) continue;
139 const nc = cc + dc, nr = cr + dr;
140 if (!passableAt(nc, nr)) continue;
141 if (dc !== 0 && dr !== 0 && (!passableAt(cc + dc, cr) || !passableAt(cc, cr + dr))) continue;
142 const ni = nr * width + nc;
143 if (seen[ni] === 1) continue;
144 seen[ni] = 1;
145 stack.push(ni);
146 }
147 }
148 }
149 return seen;
150 }
151
100152 /**
101153 * A* over the passable cells, eight ways, no corner-cutting: a diagonal is only
102154 * taken when both cells beside it are passable too, so nobody walks through the
src/engine/index.ts +2 −2
@@ -31,8 +31,8 @@ export type { Box, Hit } from "./trace.ts";
3131 export { accelerate, airAccelerate, eyeHeight, groundUnder, hullHeight, move, stuck } from "./physics.ts";
3232 export type { Body, Hull, MoveTuning, Wish } from "./physics.ts";
3333
34 /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*. */
35 export { cellAt, colOf, findPath, inside, mergeSolid, rowOf, worldOf } from "./grid.ts";
34 /** A grid of cells of any type: cell-to-world arithmetic, greedy meshing, A*, and what one cell can reach. */
35 export { cellAt, colOf, findPath, inside, mergeSolid, region, rowOf, worldOf } from "./grid.ts";
3636 export type { Grid } from "./grid.ts";
3737
3838 /** A box builder: positions, normals, uvs and a layer, finished into a `Mesh`. */
src/engine/renderer.test.ts +21 −1
@@ -62,7 +62,8 @@ function stubGl(): StubGl {
6262 "generateMipmap", "texParameteri", "bindVertexArray", "bindBuffer", "bufferData",
6363 "bufferSubData", "enableVertexAttribArray", "vertexAttribPointer", "enable", "cullFace",
6464 "viewport", "clearColor", "clear", "useProgram", "activeTexture", "uniform1i", "uniform3f",
65 "uniform3fv", "uniformMatrix4fv", "drawElements", "drawArrays",
65 "uniform3fv", "uniformMatrix4fv", "drawElements", "drawArrays", "deleteVertexArray",
66 "deleteBuffer",
6667 ]) gl[name] = note(name);
6768 return gl;
6869 }
@@ -175,6 +176,25 @@ test("construction uploads one buffer set per mesh handed in, and nothing later"
175176 expect(frameCalls(two, "texImage3D")).toEqual([]);
176177 });
177178
179 test("a mesh is replaced whole: the new one uploaded, the old one given back", () => {
180 // What a second map costs: the level's geometry, swapped under its own name,
181 // with nothing else about the renderer rebuilt and nothing left on the card.
182 const two = renderer({ level: box(), player: box(6) });
183 const madeBefore = of(two.gl, "createBuffer").length;
184 two.r.replace("level", box(4));
185 expect(of(two.gl, "createBuffer").length).toBe(madeBefore + 5);
186 expect(of(two.gl, "createVertexArray").length).toBe(2 + 1 + 1);
187 // Five buffers and one vertex array back to the driver: exactly one mesh.
188 expect(of(two.gl, "deleteBuffer").length).toBe(5);
189 expect(of(two.gl, "deleteVertexArray").length).toBe(1);
190 // And the frame draws the new one: four boxes of indices, not one.
191 two.r.draw(scene("level"), 1 / 60);
192 const drawn = frameCalls(two, "drawElements");
193 expect(drawn[0]?.[2]).toBe(box(4).index.length);
194 // A name it was never handed is a bug, here as in a frame.
195 expect(() => two.r.replace("nobody", box())).toThrow(/no mesh named "nobody"/);
196 });
197
178198 test("two instances of one mesh bind that mesh's vertex array once", () => {
179199 const r = renderer();
180200 r.r.draw(scene("level"), 1 / 60);
src/engine/renderer.ts +25 −3
@@ -121,10 +121,18 @@ export type RendererAssets = {
121121 export type Renderer = {
122122 draw(scene: Scene, dt: number): void;
123123 resize(): void;
124 /**
125 * One mesh, uploaded again under a name this renderer already holds, and the
126 * old one handed back to the driver. A game that plays a second map needs the
127 * geometry of that map and nothing else about the renderer changed, and
128 * building a second renderer over the same canvas would leak the first. A
129 * name it was never given is a bug, like a frame that names one.
130 */
131 replace(name: string, mesh: Mesh): void;
124132 readonly gl: WebGL2RenderingContext;
125133 };
126134
127 type Buffers = { vao: WebGLVertexArrayObject; count: number };
135 type Buffers = { vao: WebGLVertexArrayObject; count: number; buffers: WebGLBuffer[] };
128136
129137 /** The bytes an atlas of this shape must be: RGBA8, every layer, tightly packed. */
130138 const atlasBytes = (size: number, layers: number): number => size * size * 4 * layers;
@@ -157,8 +165,13 @@ function link(gl: WebGL2RenderingContext, vs: string, fs: string): WebGLProgram
157165 function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers {
158166 const vao = gl.createVertexArray() as WebGLVertexArrayObject;
159167 gl.bindVertexArray(vao);
168 // Kept so a mesh that is replaced can be given back whole: a vertex array
169 // does not own the buffers it points at, and a game that changes map every
170 // match would otherwise leave one level's geometry on the card per round.
171 const buffers: WebGLBuffer[] = [];
160172 const attribute = (data: Float32Array, location: number, size: number): void => {
161173 const buffer = gl.createBuffer();
174 buffers.push(buffer);
162175 gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
163176 gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
164177 gl.enableVertexAttribArray(location);
@@ -169,10 +182,11 @@ function upload(gl: WebGL2RenderingContext, mesh: Mesh): Buffers {
169182 attribute(mesh.uv, 2, 2);
170183 attribute(mesh.layer, 3, 1);
171184 const index = gl.createBuffer();
185 buffers.push(index);
172186 gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, index);
173187 gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, mesh.index, gl.STATIC_DRAW);
174188 gl.bindVertexArray(null);
175 return { vao, count: mesh.index.length };
189 return { vao, count: mesh.index.length, buffers };
176190 }
177191
178192 /**
@@ -356,5 +370,13 @@ export function createRenderer(canvas: HTMLCanvasElement, assets: RendererAssets
356370 gl.bindVertexArray(null);
357371 };
358372
359 return { draw, resize, gl };
373 /** A mesh swapped for another under the same name, the old one given back. */
374 const replace = (name: string, mesh: Mesh): void => {
375 const old = buffersFor(name);
376 uploaded.set(name, upload(gl, mesh));
377 gl.deleteVertexArray(old.vao);
378 for (const buffer of old.buffers) gl.deleteBuffer(buffer);
379 };
380
381 return { draw, resize, replace, gl };
360382 }
src/games.ts +14 −2
@@ -163,6 +163,18 @@ export type Game = {
163163 * page is: range 29,400/12,700 → 33,000/14,000 (it had 42 bytes of headroom
164164 * after #52 and 587 after #88, so this was the raise those two lines said was
165165 * coming), example 9,700/4,700 → 10,800/5,100.
166 * #138 gave the shooter maps nobody wrote — `src/client/generate.ts`, a seed in
167 * and a `Level` out — and put them on the menu beside de_forge, so this is the
168 * first entry on this page that is a feature rather than a move: the app grew
169 * 4,604 raw / 2,035 gzipped bytes for the carving, the crates, the gate that
170 * reads a finished map for flaws, and the wiring that swaps the level's mesh
171 * when a match starts on another layout. It did not fit 75,400/34,900, and the
172 * ceiling is raised here to 82,000/38,000 — today's 77,966 raw / 36,059 gzipped
173 * plus about five per cent, which is room for an honest feature and not room
174 * for a stray import of something large. The range grew 148 raw / 47 gzipped
175 * for the engine's side of that swap (`Renderer.replace`, which it never calls)
176 * and the example is unchanged to the byte: it ships no renderer at all.
177 * Measured 2026-09-20 after #138, on the bundles this branch commits.
166178 * The range's numbers here are #121's moving plates and #125's painter at once:
167179 * both landed a range bundle and neither swept the other's, so static/ held two
168180 * and every reader of it failed until a plain `bun run build` resolved them.
@@ -173,9 +185,9 @@ export const GAMES: readonly Game[] = [
173185 href: "/",
174186 label: "cs",
175187 entry: "src/client/main.ts",
176 ceiling: { raw: 75_400, gzip: 34_900 },
188 ceiling: { raw: 82_000, gzip: 38_000 },
177189 blurb:
178 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy and the leaderboard this server keeps.",
190 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.",
179191 },
180192 {
181193 name: "range",
src/pages/game.ts +6 −1
@@ -69,7 +69,8 @@ const SIGN_IN = "/__forge/signin?to=%2F";
6969 */
7070 const menu = (visitor: Who): string => `
7171 <h2>Defuse or detonate</h2>
72 <p>Five against five on <b>de_forge</b>. Bomb rounds, CS movement and economy, bots that push the sites.</p>
72 <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds,
73 CS movement and economy, bots that push the sites.</p>
7374 <p class="hint">${visitor.role === "anon"
7475 ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>.
7576 <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.`
@@ -80,9 +81,13 @@ const menu = (visitor: Who): string => `
8081 }></label>
8182 <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>
8283 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
84 <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated">generated — a new one every match</option></select></label>
8385 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
8486 </div>
8587 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
88 <p class="hint" id="onmap">A generated map is carved from the match id: two bombsites, two spawns
89 and cover, and the same map for both sides — it is the layout turned half a circle, so the walk
90 to your site is the walk to theirs.</p>
8691 <p class="hint" id="prep">Preparing textures…</p>
8792 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
8893 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
static/app.cc09e8f5e8.js deleted −58

Diff not shown — a line 57,588 characters wide (limit 2,000). −58

View file
static/app.ec73e969a1.js added +58

Diff not shown — a line 61,852 characters wide (limit 2,000). +58

View file
static/example.6352072ec9.js → static/example.f6197b684a.js renamed +1 −1

Diff not shown — a line 9,725 characters wide (limit 2,000). +1 −1

View file
static/range.e985d7477a.js deleted −58

Diff not shown — a line 17,423 characters wide (limit 2,000). −58

View file
static/range.fb4863848d.js added +58

Diff not shown — a line 17,570 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs