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

forked from trevato/cs

The hub becomes the engine's: a room of any game is opened, stepped, swept and capped by one module behind the door #353

Merged opened this

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

Merged

vesper/cs-engine:vesper/321-the-hub-becomes-the-engine-s-a-room-of-a → main 1 commit fork

  • Merged. vesper/321-the-hub-becomes-the-engine-s-a-room-of-a was merged into main as 0d73399.

Review

Sign in to review this change.
14 files changed +802 −260
README.md +19 −2
@@ -703,7 +703,14 @@ never checked itself.
703703 `action`, and both stay with the game) and `digest` (a run folded into a
704704 hash: the quantiser, the fold with a checkpoint every so many ticks, the pin
705705 and the complaint that says which span two runs parted at — the tool both
706 games pin themselves with, and the one no browser loads).
706 games pin themselves with, and the one no browser loads)
707 and `rooms` (a game played with somebody: the hub that opens rooms, steps
708 them all on one clock, sends each member their snapshot, sweeps what is
709 idle and caps what one process holds, and the six readable characters a
710 room is named by — the room type, the options it is opened with and every
711 cap arrive as arguments, so it knows no world, no map and no seat, and it
712 is the only group behind the door that runs on a server rather than in a
713 browser).
707714 Five rules keep it a package, and a test holds each: the engine imports
708715 nothing outside itself, it needs nothing from a browser to load, everything
709716 outside it comes in through one door, the version of a pack moves when that
@@ -752,7 +759,10 @@ never checked itself.
752759 `Effects`; from `renderer` `createRenderer` with `Renderer` and
753760 `RendererAssets`, which draws that frame; and from `digest` `QUANTUM`,
754761 `quantise`, `CHECKPOINT_CHARS`, `fold`, `compare`, `Fold`, `Folded`, `Pin`
755 and `Complaint`, which both games' pinned runs are folded by.
762 and `Complaint`, which both games' pinned runs are folded by; and from
763 `rooms` `createHub`, `newRoomCode` and `roomCodeFrom` with `Hub`,
764 `HubRoom`, `RoomGame`, `HubOptions` and the `Wire` a hub delivers through,
765 which is what the app's own rooms are held by.
756766 `boundary.test.ts` fails a deep import from outside, a
757767 module that is not behind the door, and any change to that list of names that
758768 is not also a change to the list written out in the test. One exception, and
@@ -1583,6 +1593,13 @@ times a second (`src/rooms/wire.ts`), so nobody in a room can disagree about wha
15831593 happened. Joining takes a bot's seat, so a round is always five against five, and
15841594 leaving hands the body straight back to the bot with the money and guns it had.
15851595
1596 The hub under that — the rooms a process holds, the one clock that steps
1597 them, the snapshot cadence, the idle sweep and the caps — is the engine's:
1598 `createHub` behind `src/engine/index.ts`, handed a table of the functions to
1599 call on a room and every cap as an argument. `src/rooms/hub.ts` is what is
1600 left of it here, which is this game's table and this game's numbers with
1601 the measurements that argue for them.
1602
15861603 What is real and what is not, today:
15871604
15881605 - Real: shared rounds, shots, damage, deaths, the bomb, the kill feed, the
src/client/main.ts +1 −2
@@ -103,10 +103,9 @@ import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
103103 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts";
104104 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
105105 import { createSession, type Session, type Socket } from "./session.ts";
106 import { roomCodeFrom } from "../rooms/code.ts";
107106 import { SWITCH_KNIFE, SWITCH_NONE, SWITCH_PRIMARY, SWITCH_SECONDARY } from "../rooms/wire.ts";
108107 import type { BuyItem as Item } from "./economy.ts";
109 import { unpack } from "../engine/index.ts";
108 import { roomCodeFrom, unpack } from "../engine/index.ts";
110109
111110 const TICK = 1 / 64;
112111
src/engine/GUIDE.md +57
@@ -736,6 +736,63 @@ what an input frame or a snapshot even contains — is `src/rooms/wire.ts` for t
736736 shooter, a file that now defines no byte plumbing of its own and holds only the
737737 part that is this game's.
738738
739 ### rooms
740
741 **Weight**: 2.1 kB raw, 1.1 kB gzipped — this module alone.
742
743 A game played with somebody else, on a server: the rooms one process is
744 holding, the one clock that steps them all, and the six characters a person
745 reads a room out loud by. It is the only part of the door a browser never runs
746 — a game imports it from the process that serves it, not from the bundle it
747 ships — and the reason it is behind the door at all is that the alternative is a
748 second copy of it in the second game that wants to be played with a friend.
749
750 **The hub is generic in the room.** `createHub` takes three things: a `Wire`, a
751 `RoomGame`, and a `HubOptions`. The `Wire` is a connection reduced to two
752 functions — deliver these bytes or this line of text to that member, close that
753 member with a code and a sentence — so nothing in here is a socket, and a test
754 hands over a wire that remembers instead of sending. The `RoomGame` is the table
755 of everything the hub calls on a room: create one, sit somebody down, stand them
756 up, drive a frame into it, step it, write a member their snapshot, say what one
757 member is told on arrival and who else is in the room, say what has been said
758 since the last beat, say whether it is idle, and describe it in a line for a
759 terminal — plus the two rates, the seconds in a step and the snapshots a second.
760 The room type is yours; the type the hub needs it to be is `HubRoom`, which is
761 three fields — the code, the members by id, and when somebody was last in it —
762 and the options a room is opened with are opaque, so a map name, a seed and a
763 round length travel from the caller of `Hub.open` to your own create without the
764 engine reading one of them. What comes back is a `Hub`: the rooms by code, the
765 four calls a server makes on it when a connection opens, speaks, goes quiet or
766 closes, the clock below, and two readings — how many members this process is
767 holding, and a line for each room.
768
769 **Every number is yours too.** `HubOptions` carries the room cap, the idle and
770 silent times, the biggest frame a member may send, the catch-up clamp, the three
771 close codes and the limiter that says how often one connection may speak — and
772 not one of them has a default here. A cap is a measurement of what a room of
773 *your* game costs a box, and the engine has measured nothing: the shooter's own
774 live in `src/rooms/hub.ts` next to the milliseconds of CPU that argue for them.
775
776 **What the clock does.** `Hub.advance` is called with the wall clock — from one
777 interval for the whole process, not one per room — and steps every room by the
778 steps that have fallen due since the last call, at most the clamp's worth, so a
779 process that was descheduled for a second does not then burn a second catching
780 up. Then the rooms whose turn it is send each member a snapshot and, when there
781 is one, the line of words since the last beat; then a member nobody has heard
782 from inside the silent time is closed, because the seat they are holding belongs
783 to somebody who is here; then a room nobody is in is dropped. Rooms live in
784 memory and die with the process: nothing in here reads or writes a file.
785
786 **And a room has a name a person can say.** `newRoomCode` draws six characters
787 from an alphabet with no O or 0, no I, l or 1 and no S against 5, out of a
788 source of random bytes you may hand in — so a test can choose a code, and
789 importing this module reaches for no global. `roomCodeFrom` is the other
790 direction and is a boundary rather than a convenience: it upper-cases what a
791 person typed and answers null for anything that is not a code, and both ends of
792 a room — the query on a socket upgrade and the field in a menu — go through it,
793 neither trusting the other. A code is never a path and never reaches markup
794 unescaped.
795
739796 ### renderer
740797
741798 **Weight**: 9.1 kB raw, 3.7 kB gzipped — including math and pixels, which come with it.
src/engine/index.ts +14
@@ -107,6 +107,20 @@ export type { Effects } from "./effects.ts";
107107 */
108108 export { Cursor, finite, packAngle, unpackAngle, unpackSigned } from "./frame.ts";
109109
110 /**
111 * A room of a game played with somebody: the hub that opens rooms, steps them
112 * all on one clock, sends each member their snapshot, sweeps what is idle and
113 * caps what one process holds — and the six readable characters a room is named
114 * by, validated on the way in wherever a code arrives. The game arrives as a
115 * `RoomGame`: the functions the hub calls on a room, whose type is the game's
116 * own and whose options are opaque here. Every number is the game's too, in
117 * `HubOptions` and with no default — a cap is a measurement, and the engine has
118 * measured nothing. Nothing in here is a socket: a `Wire` is two functions, and
119 * what one really is stays in the server that made it.
120 */
121 export { createHub, newRoomCode, roomCodeFrom } from "./rooms.ts";
122 export type { Hub, HubOptions, HubRoom, RoomGame, Wire } from "./rooms.ts";
123
110124 /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */
111125 export { createRenderer } from "./renderer.ts";
112126 export type { Renderer, RendererAssets } from "./renderer.ts";
src/engine/rooms.test.ts added +296
@@ -0,0 +1,296 @@
1 /**
2 * The hub under a game that is not the shooter: a counter with seats, twenty
3 * lines of it, played through the same `createHub` the app's rooms run on.
4 *
5 * That is the whole point of the file. The hub moved behind the door because a
6 * second game cannot be played with somebody without a second copy of it, and
7 * the proof that it moved *far enough* is that this file names no world, no map
8 * and no weapon — it imports `bun:test` and the module under it, and nothing
9 * else, which `boundary.test.ts` refuses to let it do anyway. Everything the
10 * hub actually does is read off that counter: the room cap refusing politely,
11 * an empty room collected on the sweep, a member nobody has heard from losing
12 * the seat they hold, a frame over the ceiling and a flood of frames both
13 * closing the connection with the game's own codes, the catch-up clamp on a
14 * process that was descheduled, and one clock stepping two rooms that never
15 * hear each other.
16 *
17 * The numbers below are this fake game's and are chosen to be read rather than
18 * measured: ten steps a second, four snapshots a second, three rooms, two
19 * seats. The shooter's own — `ROOM_CAP`, `IDLE_MS` and the rest — stay in
20 * `src/rooms/hub.ts` with the measurements that argue for them, and
21 * `src/rooms/hub.test.ts` is where they are played by the game they belong to.
22 */
23
24 import { expect, test } from "bun:test";
25 import {
26 ALPHABET, createHub, isRoomCode, LENGTH, newRoomCode, roomCodeFrom,
27 type HubOptions, type RoomGame, type Wire,
28 } from "./rooms.ts";
29
30 // --- a whole game, for a hub to hold ---------------------------------------
31
32 /** One person at this game: an id the wire delivers to, and the seat they took. */
33 type Seat = { id: string; seat: number };
34
35 /** A room of it: the three fields a hub reads, a count, and what it was told. */
36 type Counter = {
37 code: string;
38 members: Map<string, Seat>;
39 usedAt: number;
40 seats: number;
41 steps: number;
42 /** The first byte of every frame driven into this room, in order. */
43 driven: number[];
44 };
45
46 /** What a room of it is opened with. Opaque to the hub, which passes it on. */
47 type Options = { seats: number };
48
49 const counter: RoomGame<Counter, Seat, Options> = {
50 tick: 1 / 10,
51 snapshotHz: 4,
52 create: (code, o, now) => ({ code, members: new Map(), usedAt: now, seats: o.seats, steps: 0, driven: [] }),
53 join: (room, id, _name, now) => {
54 if (room.members.size >= room.seats) return null;
55 const seat = { id, seat: room.members.size };
56 room.members.set(id, seat);
57 room.usedAt = now;
58 return seat;
59 },
60 leave: (room, id, now) => {
61 const seat = room.members.get(id) ?? null;
62 room.members.delete(id);
63 room.usedAt = now;
64 return seat;
65 },
66 drive: (room, id, bytes) => room.members.has(id) && room.driven.push(bytes[0] as number) > 0,
67 step: (room) => { room.steps += 1; },
68 snapshot: (room, member) => new Uint8Array([room.steps & 0xff, member.seat]),
69 hello: (room, member) => ({ k: "hello", code: room.code, seat: member.seat }),
70 roster: (room) => ({ k: "roster", seats: [...room.members.values()].map((m) => m.seat) }),
71 words: (room) => (room.steps % 5 === 0 ? { k: "log", steps: room.steps } : null),
72 idle: (room, now, idle) => room.members.size === 0 && now - room.usedAt > idle,
73 describe: (room, now) => `${room.code} ${room.members.size}/${room.seats} at ${now}`,
74 };
75
76 // --- the stage --------------------------------------------------------------
77
78 type SpyWire = Wire & {
79 sent: Map<string, (Uint8Array | string)[]>;
80 closed: { id: string; code: number; reason: string }[];
81 };
82
83 function spy(): SpyWire {
84 const sent = new Map<string, (Uint8Array | string)[]>();
85 const closed: { id: string; code: number; reason: string }[] = [];
86 return {
87 sent, closed,
88 deliver(id, frame) {
89 const list = sent.get(id) ?? [];
90 list.push(frame);
91 sent.set(id, list);
92 },
93 close(id, code, reason) { closed.push({ id, code, reason }); },
94 };
95 }
96
97 /** A limiter that allows `allowed` frames and then stops, clock ignored. */
98 const allowing = (allowed: number): HubOptions["limiter"] => {
99 let left = allowed;
100 return { take: () => left-- > 0 };
101 };
102
103 /** This fake game's numbers, none of them the engine's. */
104 const CAPS = {
105 rooms: 3, idleMs: 1_000, silentMs: 2_000, messageMax: 4, maxCatchup: 5,
106 closeTooBig: 4101, closeFlood: 4102, closeSilent: 4103,
107 } as const;
108
109 const options = (over: Partial<HubOptions> = {}): HubOptions =>
110 ({ now: 0, limiter: allowing(1_000), ...CAPS, ...over });
111
112 const hubFor = (wire: Wire, over: Partial<HubOptions> = {}) => createHub(wire, counter, options(over));
113
114 /** How many snapshots — frames of bytes rather than lines of JSON — a member got. */
115 const snapshots = (wire: SpyWire, id: string): number =>
116 (wire.sent.get(id) ?? []).filter((frame) => frame instanceof Uint8Array).length;
117
118 /** And the lines of JSON, parsed, in the order they were delivered. */
119 const lines = (wire: SpyWire, id: string): Record<string, unknown>[] =>
120 (wire.sent.get(id) ?? []).filter((f): f is string => typeof f === "string").map((f) => JSON.parse(f));
121
122 // --- what the hub does ------------------------------------------------------
123
124 test("one clock steps two rooms, and a member hears their own and never the other", () => {
125 const wire = spy();
126 const hub = hubFor(wire);
127 const first = hub.open({ seats: 2 }, 0);
128 const second = hub.open({ seats: 2 }, 0);
129 expect(first).not.toBe(null);
130 expect(second).not.toBe(null);
131 const a = hub.enter((first as Counter).code, "s1", "sam", 0);
132 const b = hub.enter((second as Counter).code, "s2", "kim", 0);
133 expect(a?.member.seat).toBe(0);
134 expect(b?.room).toBe(second as Counter);
135 // Each was told which seat is theirs, in which room, and who is in it.
136 expect(lines(wire, "s1")[0]).toEqual({ k: "hello", code: (first as Counter).code, seat: 0 });
137 expect(lines(wire, "s1")[1]).toEqual({ k: "roster", seats: [0] });
138 // A code nobody opened, and a room that is full, are both a polite null.
139 expect(hub.enter("ZZZZZZ", "s3", "who", 0)).toBe(null);
140 hub.enter((first as Counter).code, "s3", "ash", 0);
141 expect(hub.enter((first as Counter).code, "s4", "jo", 0)).toBe(null);
142
143 // A second of the clock: ten steps a room, four snapshots a member.
144 for (let ms = 50; ms <= 1000; ms += 50) hub.advance(ms);
145 expect((first as Counter).steps).toBe(10);
146 expect((second as Counter).steps).toBe(10);
147 expect(snapshots(wire, "s1")).toBe(4);
148 expect(snapshots(wire, "s2")).toBe(4);
149 // The words of the round travel beside the snapshots, and only when there are some.
150 expect(lines(wire, "s1").filter((l) => l.k === "log").length).toBeGreaterThan(0);
151 expect(lines(wire, "s1").filter((l) => l.k === "log").length).toBeLessThan(4);
152
153 // One member's frame reaches their own room and no other.
154 expect(hub.frame("s1", new Uint8Array([7]), 1010)).toBe(true);
155 expect((first as Counter).driven).toEqual([7]);
156 expect((second as Counter).driven).toEqual([]);
157 // A frame from somebody who is in no room at all is refused without a close.
158 expect(hub.frame("nobody", new Uint8Array([7]), 1010)).toBe(false);
159 expect(wire.closed).toEqual([]);
160
161 expect(hub.members()).toBe(3);
162 expect(hub.find((first as Counter).code)).toBe(first as Counter);
163 expect(hub.find("ZZZZZZ")).toBe(undefined);
164 expect(hub.lines(1000)).toEqual([
165 `${(first as Counter).code} 2/2 at 1000`,
166 `${(second as Counter).code} 1/2 at 1000`,
167 ]);
168 });
169
170 test("the room cap refuses politely, and an empty room is collected on the sweep", () => {
171 const wire = spy();
172 const hub = hubFor(wire);
173 for (let i = 0; i < CAPS.rooms; i++) expect(hub.open({ seats: 2 }, 0)).not.toBe(null);
174 expect(hub.open({ seats: 2 }, 0)).toBe(null);
175 expect(hub.rooms.size).toBe(CAPS.rooms);
176 // Nobody ever joined any of them, so they go on the first sweep past the idle
177 // time — and the cap is a cap again.
178 hub.advance(CAPS.idleMs + 1);
179 expect(hub.rooms.size).toBe(0);
180 const room = hub.open({ seats: 2 }, CAPS.idleMs + 1) as Counter;
181 hub.enter(room.code, "s1", "sam", CAPS.idleMs + 1);
182 // A room with somebody in it is never idle, however long it runs.
183 hub.advance(CAPS.idleMs * 5);
184 expect(hub.rooms.size).toBe(1);
185 // They leave, and it is collected on the sweep after the idle time.
186 hub.exit("s1", CAPS.idleMs * 5);
187 expect(room.members.size).toBe(0);
188 hub.advance(CAPS.idleMs * 6 + 1);
189 expect(hub.rooms.size).toBe(0);
190 // Leaving a room that is gone, twice, is not an error and holds no seat.
191 hub.exit("s1", CAPS.idleMs * 7);
192 expect(hub.members()).toBe(0);
193 });
194
195 test("a frame over the ceiling or over the limiter closes the connection", () => {
196 const wire = spy();
197 const hub = hubFor(wire);
198 const room = hub.open({ seats: 2 }, 0) as Counter;
199 hub.enter(room.code, "s1", "sam", 0);
200 expect(hub.frame("s1", new Uint8Array(CAPS.messageMax + 1), 1)).toBe(false);
201 expect(wire.closed).toEqual([{ id: "s1", code: CAPS.closeTooBig, reason: "frame too large" }]);
202 expect(room.driven).toEqual([]);
203
204 // And a member who sends more than the limiter allows: closed, with the
205 // game's own code, and the hub is still here to say so.
206 const flooded = spy();
207 const hub2 = createHub(flooded, counter, options({ limiter: allowing(2) }));
208 const room2 = hub2.open({ seats: 2 }, 0) as Counter;
209 hub2.enter(room2.code, "s2", "kim", 0);
210 let refused = 0;
211 for (let i = 0; i < 5; i++) if (!hub2.frame("s2", new Uint8Array([i]), 1)) refused += 1;
212 expect(refused).toBe(3);
213 expect(room2.driven).toEqual([0, 1]);
214 expect(flooded.closed.every((c) => c.code === CAPS.closeFlood)).toBe(true);
215 });
216
217 test("a member nobody has heard from loses the seat they are holding", () => {
218 const wire = spy();
219 const hub = hubFor(wire);
220 const room = hub.open({ seats: 2 }, 0) as Counter;
221 hub.enter(room.code, "s1", "sam", 0);
222 hub.advance(1_500);
223 expect(wire.closed).toEqual([]);
224 // A frame is being heard from, so the silence is measured from the last one.
225 hub.frame("s1", new Uint8Array([1]), 1_500);
226 hub.advance(3_000);
227 expect(wire.closed).toEqual([]);
228 hub.advance(3_600);
229 expect(wire.closed).toEqual([{ id: "s1", code: CAPS.closeSilent, reason: "no input for 2 seconds" }]);
230 // The close is what takes them out, the way a browser closing does.
231 hub.exit("s1", 3_600);
232 expect(room.members.size).toBe(0);
233 expect(hub.members()).toBe(0);
234 });
235
236 test("a process that was descheduled catches up by the clamp and not by the gap", () => {
237 const wire = spy();
238 const hub = hubFor(wire);
239 const room = hub.open({ seats: 2 }, 0) as Counter;
240 hub.enter(room.code, "s1", "sam", 0);
241 // Ten seconds of wall clock is a hundred steps due; the clamp is five, so the
242 // room's clock runs slow for that moment rather than stealing the process.
243 hub.advance(10_000);
244 expect(room.steps).toBe(CAPS.maxCatchup);
245 // And the clock is not owed the rest: the next call steps the ticks due since
246 // the last step it actually took, which is five again and not ninety-five.
247 hub.advance(20_000);
248 expect(room.steps).toBe(CAPS.maxCatchup * 2);
249 // A step is a step of the game's, at the game's own rate: half a tick is none.
250 const fresh = hubFor(spy());
251 const slow = fresh.open({ seats: 1 }, 0) as Counter;
252 fresh.advance(50);
253 expect(slow.steps).toBe(0);
254 fresh.advance(120);
255 expect(slow.steps).toBe(1);
256 });
257
258 // --- what a room is called --------------------------------------------------
259
260 test("a room code is six readable characters, and nothing else is one", () => {
261 expect(isRoomCode("ABCDEF")).toBe(true);
262 expect(isRoomCode("abcdef")).toBe(false);
263 expect(isRoomCode("ABCDE")).toBe(false);
264 // The characters a person reads wrong are not in the alphabet at all.
265 for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false);
266 expect(isRoomCode("ABCDE0")).toBe(false);
267 expect(isRoomCode({ code: "ABCDEF" })).toBe(false);
268 // The boundary upper-cases what a person typed and refuses what is not a code.
269 expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF");
270 expect(roomCodeFrom("../../etc/passwd")).toBe(null);
271 expect(roomCodeFrom(null)).toBe(null);
272 // The shape is a written-out literal so that a bundle can shake it away, so
273 // every character of the alphabet is held against it here, and the length is
274 // held on both sides of itself.
275 for (const symbol of ALPHABET) expect(isRoomCode(symbol.repeat(LENGTH))).toBe(true);
276 expect(isRoomCode(("A" as string).repeat(LENGTH - 1))).toBe(false);
277 expect(isRoomCode(("A" as string).repeat(LENGTH + 1))).toBe(false);
278 });
279
280 test("a fresh code is drawn from the alphabet and is the stated length", () => {
281 for (let i = 0; i < 50; i++) {
282 const code = newRoomCode();
283 expect(code.length).toBe(LENGTH);
284 expect(isRoomCode(code)).toBe(true);
285 }
286 // And it is drawn from the bytes it is handed, so a test can choose one.
287 expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH));
288 });
289
290 test("a room's code is the hub's own, and a collision is drawn again", () => {
291 const wire = spy();
292 const hub = hubFor(wire);
293 const opened = [0, 1, 2].map(() => hub.open({ seats: 2 }, 0) as Counter);
294 for (const room of opened) expect(isRoomCode(room.code)).toBe(true);
295 expect(new Set(opened.map((room) => room.code)).size).toBe(3);
296 });
src/engine/rooms.ts added +314
@@ -0,0 +1,314 @@
1 /**
2 * A room of *a* game, opened, stepped, swept and capped: every room one process
3 * is holding, the one clock that steps them all, and the six readable characters
4 * a person reads a room out loud by.
5 *
6 * Nothing in here knows a world, a map, a weapon or a seat. The hub is handed a
7 * table of the functions it calls on a room — create it, sit somebody down,
8 * stand them up, drive a frame into it, step it, write a snapshot for a member,
9 * ask what was said since the last beat, ask whether it is idle, describe it —
10 * and the room itself is the game's own type, opened with the game's own opaque
11 * options. So the hub is the same code whether a room is a ten-body shooter or a
12 * counter with two seats, and `rooms.test.ts` plays the second one.
13 *
14 * Nor is anything in here a socket. The hub is handed a `Wire` — deliver these
15 * bytes to that member, close that member — and the server is the only file that
16 * knows what a connection really is. That is what lets a hub of five members, a
17 * flood, an idle sweep and a room cap be tested without opening a port.
18 *
19 * **Every number is the game's.** A room is CPU on somebody's box, and how much
20 * of it a room costs is a fact about that game and not about this file: the room
21 * cap, the idle and silent times, the frame ceiling, the catch-up clamp and the
22 * close codes all arrive as `HubOptions`, and so does the limiter that says how
23 * often one connection may speak. The engine decides none of them and has no
24 * default for any of them — a default here would be a number with no measurement
25 * behind it, which is the one thing a cap must never be.
26 *
27 * Rooms live in memory and die with the process: nothing here reads or writes a
28 * file, and a hub that is dropped is a hub that is gone.
29 */
30
31 // --- what a room is called -------------------------------------------------
32
33 /**
34 * The symbols a code is made of, and the only ones. The alphabet leaves out the
35 * characters a person reads wrong: no O or 0, no I, l or 1, no S against 5.
36 * That is 29 symbols, so a code is 29^6 ≈ 594 million, and against the twenty or
37 * so rooms a process holds a blind guess finds one about once in thirty million
38 * tries — against a limiter that allows nowhere near that many.
39 */
40 export const ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ2346789";
41
42 /** How many characters a code is. */
43 export const LENGTH = 6;
44
45 /**
46 * The shape of a code, written out rather than built from the two constants
47 * above. A `new RegExp` at module scope is a side effect a minifier may not
48 * drop, so building it would put a hundred bytes of room code into the bundle
49 * of every game that imports the door and never opens a room; a literal is
50 * shaken away with the rest of this module. `rooms.test.ts` holds it against
51 * `ALPHABET` and `LENGTH` character by character, which is the drift a written
52 * copy would otherwise invite.
53 */
54 const SHAPE = /^[ABCDEFGHJKMNPQRTUVWXYZ2346789]{6}$/;
55
56 /** Is this a room code at all? The shape above, and nothing else. */
57 export const isRoomCode = (value: unknown): value is string =>
58 typeof value === "string" && SHAPE.test(value);
59
60 /**
61 * The code a caller handed over, upper-cased first because a person typing one
62 * types it however their keyboard was, or null when it is not a code. This is
63 * the boundary: a server's socket upgrade goes through it and a page's field
64 * goes through it again, and neither trusts the other. A code is never a path,
65 * never a key in anything on disk, and never interpolated into markup without
66 * escaping.
67 */
68 export function roomCodeFrom(value: unknown): string | null {
69 if (typeof value !== "string") return null;
70 const code = value.trim().toUpperCase();
71 return isRoomCode(code) ? code : null;
72 }
73
74 /**
75 * A fresh code. `bytes` is a source of random bytes — `crypto.getRandomValues`
76 * in both a browser and a server — and is a parameter so a test can hand over a
77 * sequence it chose, and so importing this module reaches for no global.
78 *
79 * The modulo is not quite uniform over 256: with 29 symbols the first 24 are
80 * drawn slightly more often than the last 5, by about four per cent. That is
81 * not a weakness worth code here — it costs a fraction of a bit out of the
82 * thirty a code carries — and rejection sampling would be a loop with a
83 * pathological case in the one place this cannot afford one.
84 */
85 export function newRoomCode(bytes: (into: Uint8Array) => Uint8Array = (into) => crypto.getRandomValues(into)): string {
86 const drawn = bytes(new Uint8Array(LENGTH));
87 let code = "";
88 for (let i = 0; i < LENGTH; i++) code += ALPHABET[(drawn[i] as number) % ALPHABET.length];
89 return code;
90 }
91
92 // --- the hub ---------------------------------------------------------------
93
94 /** What the hub needs a connection to be, which is two functions. */
95 export type Wire = {
96 /** These bytes, or this line of JSON, to that member. */
97 deliver(id: string, frame: Uint8Array | string): void;
98 /** That member, disconnected, with a code and a sentence. */
99 close(id: string, code: number, reason: string): void;
100 };
101
102 /**
103 * What the hub needs a *room* to be, which is three fields: the code it answers
104 * to, who is in it, and when somebody last was. Everything else about a room —
105 * the world, the level, the round, the buffers — is the game's and is never read
106 * here. `M`, a member, is likewise the game's own, and the hub reads one field
107 * of it: the id the wire delivers to.
108 */
109 export type HubRoom<M extends { id: string }> = {
110 code: string;
111 members: Map<string, M>;
112 /** When the room last had a member in it, on the wall clock. */
113 usedAt: number;
114 };
115
116 /**
117 * The game, as the hub sees it: the functions it calls on a room, the step its
118 * world takes and how often a member is sent one. `O` is whatever a room is
119 * opened with — a map, a seed, a length, a nothing — and travels from the caller
120 * of `open` to `create` without being read on the way.
121 */
122 export type RoomGame<R extends HubRoom<M>, M extends { id: string }, O> = {
123 /** Seconds in one step of a room's world. */
124 tick: number;
125 /** How many snapshots a member is sent a second. */
126 snapshotHz: number;
127 /** A fresh room under that code, opened with the game's own options. */
128 create(code: string, options: O, now: number): R;
129 /** Somebody sits down. Null is a full room, which is the member cap. */
130 join(room: R, id: string, name: unknown, now: number): M | null;
131 /** And stands up again. Null is somebody who was not in it. */
132 leave(room: R, id: string, now: number): M | null;
133 /** One frame off the wire, for the member it came from. False is refused. */
134 drive(room: R, id: string, bytes: Uint8Array): boolean;
135 /** One step of this room's world. */
136 step(room: R): void;
137 /** The bytes this member is sent now. */
138 snapshot(room: R, member: M): Uint8Array;
139 /** What one member is told the moment they are in. Delivered as JSON. */
140 hello(room: R, member: M): unknown;
141 /** Who is in the room, for everybody in it. Delivered as JSON when it changes. */
142 roster(room: R): unknown;
143 /** What was said since the last beat, or null when nothing was. JSON. */
144 words(room: R): unknown;
145 /** A room nobody is in, for longer than `idle` milliseconds. */
146 idle(room: R, now: number, idle: number): boolean;
147 /** One line about a room, for a terminal. */
148 describe(room: R, now: number): string;
149 };
150
151 /**
152 * The numbers a hub runs on, every one of them the game's. None has a default:
153 * a cap is a measurement, and a measurement belongs to whoever measured it.
154 */
155 export type HubOptions = {
156 /** The wall clock the hub starts stepping from. */
157 now: number;
158 /** How often one connection may send a frame. The key is the member's id. */
159 limiter: { take(key: string, now: number): boolean };
160 /** How many rooms this process holds at once. Past it `open` answers null. */
161 rooms: number;
162 /** How long a room with nobody in it is kept, in milliseconds. */
163 idleMs: number;
164 /** How long a connection that says nothing at all is kept, in milliseconds. */
165 silentMs: number;
166 /** The most bytes one frame from a member may be. Past it, closed. */
167 messageMax: number;
168 /** The most steps one call of `advance` will take a room through. */
169 maxCatchup: number;
170 /** The close code for a frame over `messageMax`. */
171 closeTooBig: number;
172 /** The close code for a member over the limiter. */
173 closeFlood: number;
174 /** The close code for a member nobody has heard from in `silentMs`. */
175 closeSilent: number;
176 };
177
178 export type Hub<R extends HubRoom<M>, M extends { id: string }, O> = {
179 rooms: Map<string, R>;
180 /** A fresh room, or null when this process is already holding its cap. */
181 open(options: O, now: number): R | null;
182 find(code: string): R | undefined;
183 /** Somebody joins a room by code. Null is a full room or a room that is gone. */
184 enter(code: string, id: string, name: unknown, now: number): { room: R; member: M } | null;
185 /** And leaves it, whichever room they were in. */
186 exit(id: string, now: number): void;
187 /** One frame off the wire from a member. False when it was refused. */
188 frame(id: string, bytes: Uint8Array, now: number): boolean;
189 /**
190 * The clock: step every room by however many ticks are due at `now`, send the
191 * snapshots that fall due, and drop what is idle or silent. Called from one
192 * interval in a server and from a test with a clock it moves itself.
193 */
194 advance(now: number): void;
195 /** How many members this process is holding, over every room. */
196 members(): number;
197 /** One line per room, for a terminal. */
198 lines(now: number): string[];
199 };
200
201 /** Where a member is: which room, and since when they were last heard from. */
202 type Seated = { code: string; heardAt: number };
203
204 export function createHub<R extends HubRoom<M>, M extends { id: string }, O>(
205 wire: Wire,
206 game: RoomGame<R, M, O>,
207 options: HubOptions,
208 ): Hub<R, M, O> {
209 const rooms = new Map<string, R>();
210 const where = new Map<string, Seated>();
211 const { limiter } = options;
212 /** The wall clock of the last step, so a tick is never stepped twice. */
213 let steppedAt = options.now;
214 /** When each room last sent its snapshots, on the wall clock. */
215 const sentAt = new Map<string, number>();
216 /** The sentence a silent member is closed with, in the game's own seconds. */
217 const silent = `no input for ${Math.round(options.silentMs / 1000)} seconds`;
218
219 /** Everybody in the room hears the same line of JSON. */
220 const tell = (room: R, message: unknown): void => {
221 const line = JSON.stringify(message);
222 for (const member of room.members.values()) wire.deliver(member.id, line);
223 };
224
225 const hub: Hub<R, M, O> = {
226 rooms,
227 open(o, now) {
228 if (rooms.size >= options.rooms) return null;
229 // A collision is a room that exists, not an error: draw again. Thirty bits
230 // against a handful of rooms means this loop runs once, and it is bounded
231 // anyway.
232 let code = newRoomCode();
233 for (let tries = 0; tries < 8 && rooms.has(code); tries++) code = newRoomCode();
234 if (rooms.has(code)) return null;
235 const room = game.create(code, o, now);
236 rooms.set(code, room);
237 sentAt.set(code, now);
238 return room;
239 },
240 find: (code) => rooms.get(code),
241 enter(code, id, name, now) {
242 const room = rooms.get(code);
243 if (room === undefined) return null;
244 const member = game.join(room, id, name, now);
245 if (member === null) return null;
246 where.set(id, { code, heardAt: now });
247 wire.deliver(id, JSON.stringify(game.hello(room, member)));
248 // Everybody, the arrival included, gets the roster the arrival changed.
249 tell(room, game.roster(room));
250 return { room, member };
251 },
252 exit(id, now) {
253 const seated = where.get(id);
254 where.delete(id);
255 if (seated === undefined) return;
256 const room = rooms.get(seated.code);
257 if (room === undefined) return;
258 if (game.leave(room, id, now) === null) return;
259 tell(room, game.roster(room));
260 },
261 frame(id, bytes, now) {
262 const seated = where.get(id);
263 if (seated === undefined) return false;
264 if (bytes.byteLength > options.messageMax) {
265 wire.close(id, options.closeTooBig, "frame too large");
266 return false;
267 }
268 if (!limiter.take(`ws:${id}`, now)) {
269 wire.close(id, options.closeFlood, "too many frames");
270 return false;
271 }
272 seated.heardAt = now;
273 const room = rooms.get(seated.code);
274 return room !== undefined && game.drive(room, id, bytes);
275 },
276 advance(now) {
277 // Every step that has fallen due since the last call, capped: a process
278 // that was descheduled for a second does not then burn a second of CPU
279 // catching up on every room at once. Past the cap the rooms' clocks simply
280 // run slow for that moment, which is a hitch and not a hole.
281 const due = Math.min(Math.floor((now - steppedAt) / (game.tick * 1000)), options.maxCatchup);
282 if (due > 0) steppedAt += due * game.tick * 1000;
283 const interval = 1000 / game.snapshotHz;
284 for (const [code, room] of rooms) {
285 for (let i = 0; i < due; i++) game.step(room);
286 if (room.members.size > 0) room.usedAt = now;
287 const last = sentAt.get(code) ?? now;
288 if (now - last >= interval) {
289 sentAt.set(code, now);
290 const said = game.words(room);
291 const words = said === null ? null : JSON.stringify(said);
292 for (const member of room.members.values()) {
293 wire.deliver(member.id, game.snapshot(room, member));
294 if (words !== null) wire.deliver(member.id, words);
295 }
296 }
297 }
298 // A member nobody has heard from is holding a seat somebody else could
299 // have: closed here, and the close is what takes them out of the room.
300 for (const [id, seated] of where) {
301 if (now - seated.heardAt > options.silentMs) wire.close(id, options.closeSilent, silent);
302 }
303 for (const [code, room] of rooms) {
304 if (game.idle(room, now, options.idleMs)) {
305 rooms.delete(code);
306 sentAt.delete(code);
307 }
308 }
309 },
310 members: () => where.size,
311 lines: (now) => [...rooms.values()].map((room) => game.describe(room, now)),
312 };
313 return hub;
314 }
src/engine/surface.json +9 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.7.0",
2 "version": "0.8.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -39,6 +39,9 @@
3939 "Grid",
4040 "Hit",
4141 "Host",
42 "Hub",
43 "HubOptions",
44 "HubRoom",
4245 "Hull",
4346 "Hyper",
4447 "Instance",
@@ -66,6 +69,7 @@
6669 "Renderer",
6770 "RendererAssets",
6871 "Rollout",
72 "RoomGame",
6973 "Scene",
7074 "SceneCapacity",
7175 "Screen",
@@ -81,6 +85,7 @@
8185 "UpdateStats",
8286 "Vec3",
8387 "WheelEventish",
88 "Wire",
8489 "Wish",
8590 "accelerate",
8691 "add",
@@ -107,6 +112,7 @@
107112 "createEffects",
108113 "createEscape",
109114 "createFullscreen",
115 "createHub",
110116 "createLoop",
111117 "createPolicy",
112118 "createRenderer",
@@ -145,6 +151,7 @@
145151 "mul",
146152 "multiply",
147153 "newActivations",
154 "newRoomCode",
148155 "nextInstance",
149156 "noise",
150157 "normalize",
@@ -169,6 +176,7 @@
169176 "resetEffects",
170177 "resetScene",
171178 "rng",
179 "roomCodeFrom",
172180 "rowOf",
173181 "sample",
174182 "scale",
src/independence.test.ts +11 −11
@@ -67,9 +67,10 @@ const TOOLS = [resolve(ROOT, "scripts", "build.ts"), resolve(ROOT, "scripts", "b
6767 * played by more than one person, not a service beside it. Left as the app's —
6868 * which is what it was, because nothing said otherwise — `room.ts` could import
6969 * `src/range/` as freely as `src/client/`, and that one line is exactly what
70 * this file exists to refuse. Two of the four modules here reach for no game at
71 * all and are the engine's to take; the test below reads them and says so,
72 * rather than the next issue starting from a reading.
70 * this file exists to refuse. Two of the four modules that were here reached
71 * for no game at all and have been taken by the engine — the hub's machinery
72 * and the room code are `src/engine/rooms.ts` now — and the three that are left
73 * are this game's; the test below reads them and says so.
7374 */
7475 const ALSO: Record<string, readonly string[]> = { app: ["src/rl", "src/rooms"] };
7576
@@ -112,7 +113,6 @@ test("every game has the sources this test is about, and the app has none", () =
112113 expect(of("app")).toContain("src/rooms/room.ts");
113114 expect(of("app")).toContain("src/rooms/wire.ts");
114115 expect(of("app")).toContain("src/rooms/hub.ts");
115 expect(of("app")).toContain("src/rooms/code.ts");
116116 expect(of("range")).toContain("src/range/targets.ts");
117117 expect(of("range")).toContain("src/range/main.ts");
118118 // The third game was covered by its registry entry and nothing else: this
@@ -233,13 +233,13 @@ function reachesAGame(file: string): string[] {
233233 .filter((target) => gameOf(target) !== null && !target.startsWith(`${own}/`));
234234 }
235235
236 test("the two room modules that reach for no game are read rather than believed", () => {
237 // The step after this one moves `code.ts` and `hub.ts` into the engine, where
238 // a module may import no game at all. That they already import none is a fact
239 // about the sources, so it is read out of them here: the move starts from
240 // this line, and the day somebody adds the convenient import it fails here
241 // rather than in the middle of the move.
242 expect(reachesAGame("src/rooms/code.ts")).toEqual([]);
236 test("what is left of the rooms is the shooter's, and is read rather than believed", () => {
237 // The move happened: `code.ts` and the hub's machinery are `src/engine/
238 // rooms.ts`, behind the door, where a module may import no game at all. What
239 // is left under `src/rooms/hub.ts` is this game's table and this game's
240 // numbers, so it reaches for `room.ts` — its own sibling — and for the door,
241 // and for no other game's source. That is a fact about the sources, so it is
242 // read out of them here rather than believed.
243243 expect(reachesAGame("src/rooms/hub.ts")).toEqual([]);
244244 // The other two are the shooter's, and say so themselves.
245245 expect(reachesAGame("src/rooms/room.ts")).toContain("src/client/world.ts");
src/rooms/code.test.ts deleted −36
@@ -1,36 +0,0 @@
1 /**
2 * What a room is called, both ways through the door: what is a code, what is
3 * not one, and that a fresh one is drawn from the alphabet a person can read
4 * out loud without asking which letter that was.
5 *
6 * `code.ts` imports nothing at all, so this file needs no world, no clock and
7 * no fixture — it is the whole of the module's surface in two tests, which is
8 * the point of the module being that small.
9 */
10
11 import { expect, test } from "bun:test";
12 import { ALPHABET, isRoomCode, LENGTH, newRoomCode, roomCodeFrom } from "./code.ts";
13
14 test("a room code is six readable characters, and nothing else is one", () => {
15 expect(isRoomCode("ABCDEF")).toBe(true);
16 expect(isRoomCode("abcdef")).toBe(false);
17 expect(isRoomCode("ABCDE")).toBe(false);
18 // The characters a person reads wrong are not in the alphabet at all.
19 for (const wrong of ["O", "0", "I", "1", "L", "S", "5"]) expect(ALPHABET.includes(wrong)).toBe(false);
20 expect(isRoomCode("ABCDE0")).toBe(false);
21 expect(isRoomCode({ code: "ABCDEF" })).toBe(false);
22 // The boundary upper-cases what a person typed and refuses what is not a code.
23 expect(roomCodeFrom(" abcdef ")).toBe("ABCDEF");
24 expect(roomCodeFrom("../../etc/passwd")).toBe(null);
25 expect(roomCodeFrom(null)).toBe(null);
26 });
27
28 test("a fresh code is drawn from the alphabet and is the stated length", () => {
29 for (let i = 0; i < 50; i++) {
30 const code = newRoomCode();
31 expect(code.length).toBe(LENGTH);
32 expect(isRoomCode(code)).toBe(true);
33 }
34 // And it is drawn from the bytes it is handed, so a test can choose one.
35 expect(newRoomCode((into) => into.fill(0))).toBe((ALPHABET[0] as string).repeat(LENGTH));
36 });
src/rooms/code.ts deleted −63
@@ -1,63 +0,0 @@
1 /**
2 * What a room is called: six characters a person can read out loud over a
3 * voice call and type without asking which letter that was.
4 *
5 * It is the whole of the public name of a room, so it is an input twice over —
6 * the query on the socket upgrade, and the field in the menu — and it is
7 * validated here on both sides, the way `src/matchid.ts` is the one door a match
8 * id goes through. A code is never a path, never a key in anything on disk, and
9 * never interpolated into markup without escaping.
10 *
11 * The alphabet leaves out the characters a person reads wrong: no O or 0, no I,
12 * l or 1, no S against 5. That is 32 symbols, so a code is 32^6 ≈ 1.07 billion,
13 * and with the room cap this process holds — a hundred — a blind guess finds a
14 * room about once in ten million tries, against a limiter that allows nowhere
15 * near that many. The code is drawn from `crypto.getRandomValues`, not from
16 * `Math.random`: it is the only thing standing between a room and a stranger.
17 *
18 * This file imports nothing — not the world, not the hub, not a package — so
19 * `code.test.ts` is the whole of it in two tests: what is a code, and that a
20 * fresh one is drawn from the alphabet above and is the stated length.
21 */
22
23 /** The symbols a code is made of, and the only ones. */
24 export const ALPHABET = "ABCDEFGHJKMNPQRTUVWXYZ2346789";
25
26 /** How many characters a code is. */
27 export const LENGTH = 6;
28
29 const SHAPE = new RegExp(`^[${ALPHABET}]{${LENGTH}}$`);
30
31 /** Is this a room code at all? The shape above, and nothing else. */
32 export const isRoomCode = (value: unknown): value is string =>
33 typeof value === "string" && SHAPE.test(value);
34
35 /**
36 * The code a caller handed over, upper-cased first because a person typing one
37 * types it however their keyboard was, or null when it is not a code. This is
38 * the boundary: the server's upgrade goes through it and the page's field goes
39 * through it again, and neither trusts the other.
40 */
41 export function roomCodeFrom(value: unknown): string | null {
42 if (typeof value !== "string") return null;
43 const code = value.trim().toUpperCase();
44 return isRoomCode(code) ? code : null;
45 }
46
47 /**
48 * A fresh code. `bytes` is a source of random bytes — `crypto.getRandomValues`
49 * in both a browser and Bun — and is a parameter so a test can hand over a
50 * sequence it chose.
51 *
52 * The modulo is not quite uniform over 256: with 29 symbols the first 24 are
53 * drawn slightly more often than the last 5, by about four per cent. That is
54 * not a weakness worth code here — it costs a fraction of a bit out of the
55 * thirty a code carries — and rejection sampling would be a loop with a
56 * pathological case in the one place this app cannot afford one.
57 */
58 export function newRoomCode(bytes: (into: Uint8Array) => Uint8Array = (into) => crypto.getRandomValues(into)): string {
59 const drawn = bytes(new Uint8Array(LENGTH));
60 let code = "";
61 for (let i = 0; i < LENGTH; i++) code += ALPHABET[(drawn[i] as number) % ALPHABET.length];
62 return code;
63 }
src/rooms/hub.test.ts +18 −5
@@ -8,9 +8,16 @@
88 * sweep past the idle time, and a connection that says nothing losing the seat
99 * it is holding.
1010 *
11 * The machinery under all of that is the engine's now, and
12 * `src/engine/rooms.test.ts` is where it is proved against a game that is not
13 * this one. What is proved *here* is this game wired to it: the shooter's table
14 * and the shooter's numbers, playing a real round of the real world. The two
15 * files are the same behaviours read twice on purpose — the engine's says the
16 * hub is generic, this one says the shooter still plays.
17 *
1118 * It is also where the directory is held to its own documentation: every test
1219 * file a source under `src/rooms/` names in backticks is a file that exists,
13 * and each of the four modules names the test file that is about it. The habit
20 * and each of the three modules names the test file that is about it. The habit
1421 * is `src/engine/guide.test.ts`'s — prose that names a file is a claim, and a
1522 * claim is read by a test — and it lives here because the hub is the module
1623 * that names a test outside its own directory.
@@ -121,8 +128,12 @@ test("a member nobody has heard from loses the seat they are holding", () => {
121128 const ROOMS = import.meta.dir;
122129 const ROOT = resolve(ROOMS, "..", "..");
123130
124 /** The four modules of this directory, each of which names its own test file. */
125 const MODULES = ["code", "wire", "room", "hub"];
131 /**
132 * The three modules of this directory, each of which names its own test file.
133 * There were four: `code.ts` went behind the engine's door with the hub's
134 * machinery, and what is left in here is the shooter's own.
135 */
136 const MODULES = ["wire", "room", "hub"];
126137
127138 /**
128139 * The test files a source names in backticks: either a sibling under this
@@ -158,9 +169,11 @@ test("each module names the test file that is about it, and that file tests it",
158169 const spec = readFileSync(join(ROOMS, `${module}.test.ts`), "utf8");
159170 expect(spec).toContain(`"./${module}.ts"`);
160171 }
161 // The file the four replaced is gone, rather than left beside them saying
162 // the same things twice.
172 // The file the split replaced is gone, rather than left beside them saying
173 // the same things twice — and so is `code.ts`, which the engine holds now.
163174 expect(existsSync(join(ROOMS, "rooms.test.ts"))).toBe(false);
175 expect(existsSync(join(ROOMS, "code.ts"))).toBe(false);
176 expect(existsSync(join(ROOMS, "code.test.ts"))).toBe(false);
164177 });
165178
166179 test("the reader reads a name it is handed, and only a name", () => {
src/rooms/hub.ts +59 −136
@@ -1,5 +1,12 @@
11 /**
2 * Every room this process is holding, and the one clock that steps them.
2 * This game's rooms: the numbers a room of *this* shooter costs, and the table
3 * that hands the shooter's own room to the engine's hub.
4 *
5 * The hub itself is `createHub` behind the engine's door — it opens rooms, steps
6 * them on one clock, sends the snapshots that fall due, sweeps what is idle and
7 * caps what one process holds, and it knows none of that is a shooter. What is
8 * left here is what is genuinely this game's: the functions of `room.ts` it
9 * calls, and the numbers below.
310 *
411 * A room is CPU on a shared box, so this file is mostly numbers with reasons,
512 * and the numbers are measured rather than assumed: `bun run scripts/room.ts`
@@ -10,14 +17,16 @@
1017 * core. `ROOM_CAP` rooms at once is therefore about a sixth of a core, and one
1118 * core would hold fifty to eighty of them. That is the whole argument for the
1219 * cap being where it is: it is a choice about a box that also serves pages, not
13 * a ceiling the simulation put there. `src/budget.test.ts` holds a room-second
20 * a ceiling the simulation put there, and it is the argument for these numbers
21 * living here rather than in the engine. `src/budget.test.ts` holds a room-second
1422 * under 40 ms so the day the simulation gets several times dearer this file's
1523 * arithmetic goes red rather than silently wrong.
1624 *
1725 * Nothing here is a socket either. The hub is handed a `Wire` — deliver these
1826 * bytes to that member, close that member — and the server is the only file that
1927 * knows what one really is. So `hub.test.ts` runs a hub with five members, an
20 * idle sweep and a room cap without opening a port.
28 * idle sweep and a room cap without opening a port, and `src/engine/rooms.test.ts`
29 * runs the same machinery under a game that is not this one.
2130 *
2231 * Rooms live in memory and die with the process. Nothing is written under
2332 * `FORGE_APP_DATA`: a half-finished round is not worth keeping, a deploy ends
@@ -25,14 +34,16 @@
2534 * survives one.
2635 */
2736
37 import { createHub as createEngineHub, type Hub as EngineHub, type RoomGame, type Wire } from "../engine/index.ts";
2838 import { createLimiter, type Limiter } from "../limit.ts";
29 import { newRoomCode } from "./code.ts";
3039 import {
3140 createRoom, describeRoom, drive, helloFor, isIdle, join, leave, logSince, rosterMessage, snapshotFor,
3241 stepRoom, SNAPSHOT_HZ, TICK, type Member, type Room,
3342 } from "./room.ts";
3443 import type { PlayedMap } from "../mapname.ts";
3544
45 export type { Wire };
46
3647 /**
3748 * How many rooms one deployment holds at once. Twenty rooms is a measured
3849 * 0.13–0.18 s of CPU a second — about a sixth of a core — against a box that
@@ -89,142 +100,54 @@ export const FRAME_LIMIT = { burst: 160, per: 8 };
89100 */
90101 export const MAX_CATCHUP = 16;
91102
92 /** What the hub needs a connection to be, which is two functions. */
93 export type Wire = {
94 /** These bytes, or this line of JSON, to that member. */
95 deliver(id: string, frame: Uint8Array | string): void;
96 /** That member, disconnected, with a code and a sentence. */
97 close(id: string, code: number, reason: string): void;
103 /** What a room of this game is opened with. Opaque to the hub, which passes it on. */
104 export type RoomOptions = { map: PlayedMap; seed: number; length: "short" | "long"; now: number };
105
106 /**
107 * The shooter, as the hub sees it: eleven functions of `room.ts` and the two
108 * rates a room runs at. This is the whole of what the engine knows about this
109 * game — no `World`, no `PlayedMap`, no map name — and the only thing that had
110 * to be written down to get the hub out of here.
111 */
112 export const SHOOTER: RoomGame<Room, Member, RoomOptions> = {
113 tick: TICK,
114 snapshotHz: SNAPSHOT_HZ,
115 create: (code, options) => createRoom(code, options),
116 join,
117 leave,
118 drive,
119 step: stepRoom,
120 snapshot: snapshotFor,
121 hello: helloFor,
122 roster: rosterMessage,
123 words: logSince,
124 idle: isIdle,
125 describe: describeRoom,
98126 };
99127
100 export type Hub = {
101 rooms: Map<string, Room>;
128 /**
129 * Every room this process is holding. The engine's hub with this game's table
130 * and this game's numbers in it, and one difference at the door: a room here
131 * carries the clock it was opened at, so `open` takes the options alone where
132 * the engine's takes a `now` beside them.
133 */
134 export type Hub = Omit<EngineHub<Room, Member, RoomOptions>, "open"> & {
102135 /** A fresh room, or null when this process is already holding `ROOM_CAP`. */
103 open(options: { map: PlayedMap; seed: number; length: "short" | "long"; now: number }): Room | null;
104 find(code: string): Room | undefined;
105 /** Somebody joins a room by code. Null is a full room or a room that is gone. */
106 enter(code: string, id: string, name: unknown, now: number): { room: Room; member: Member } | null;
107 /** And leaves it, whichever room they were in. */
108 exit(id: string, now: number): void;
109 /** One frame off the wire from a member. False when it was refused. */
110 frame(id: string, bytes: Uint8Array, now: number): boolean;
111 /**
112 * The clock: step every room by however many ticks are due at `now`, send the
113 * snapshots that fall due, and drop what is idle or silent. Called from one
114 * interval in `src/server.ts` and from a test with a clock it moves itself.
115 */
116 advance(now: number): void;
117 /** How many members this process is holding, over every room. */
118 members(): number;
119 /** One line per room, for the terminal. */
120 lines(now: number): string[];
136 open(options: RoomOptions): Room | null;
121137 };
122138
123 /** Where a member is: which room, and since when they were last heard from. */
124 type Seated = { code: string; heardAt: number };
125
126139 export function createHub(wire: Wire, options: { now: number; limiter?: Limiter } = { now: Date.now() }): Hub {
127 const rooms = new Map<string, Room>();
128 const where = new Map<string, Seated>();
129 const limiter = options.limiter ?? createLimiter(FRAME_LIMIT);
130 /** The wall clock of the last step, so a tick is never stepped twice. */
131 let steppedAt = options.now;
132 /** When each room last sent its snapshots, on the wall clock. */
133 const sentAt = new Map<string, number>();
134
135 const send = (room: Room, member: Member): void => {
136 wire.deliver(member.id, snapshotFor(room, member));
137 };
138
139 const hub: Hub = {
140 rooms,
141 open(o) {
142 if (rooms.size >= ROOM_CAP) return null;
143 // A collision is a room that exists, not an error: draw again. Thirty bits
144 // against twenty rooms means this loop runs once, and it is bounded anyway.
145 let code = newRoomCode();
146 for (let tries = 0; tries < 8 && rooms.has(code); tries++) code = newRoomCode();
147 if (rooms.has(code)) return null;
148 const room = createRoom(code, o);
149 rooms.set(code, room);
150 sentAt.set(code, o.now);
151 return room;
152 },
153 find: (code) => rooms.get(code),
154 enter(code, id, name, now) {
155 const room = rooms.get(code);
156 if (room === undefined) return null;
157 const member = join(room, id, name, now);
158 if (member === null) return null;
159 where.set(id, { code, heardAt: now });
160 wire.deliver(id, JSON.stringify(helloFor(room, member)));
161 // Everybody, the arrival included, gets the roster the arrival changed.
162 const roster = JSON.stringify(rosterMessage(room));
163 for (const other of room.members.values()) wire.deliver(other.id, roster);
164 return { room, member };
165 },
166 exit(id, now) {
167 const seated = where.get(id);
168 where.delete(id);
169 if (seated === undefined) return;
170 const room = rooms.get(seated.code);
171 if (room === undefined) return;
172 if (leave(room, id, now) === null) return;
173 const roster = JSON.stringify(rosterMessage(room));
174 for (const other of room.members.values()) wire.deliver(other.id, roster);
175 },
176 frame(id, bytes, now) {
177 const seated = where.get(id);
178 if (seated === undefined) return false;
179 if (bytes.byteLength > MESSAGE_MAX) {
180 wire.close(id, CLOSE_TOO_BIG, "frame too large");
181 return false;
182 }
183 if (!limiter.take(`ws:${id}`, now)) {
184 wire.close(id, CLOSE_FLOOD, "too many frames");
185 return false;
186 }
187 seated.heardAt = now;
188 const room = rooms.get(seated.code);
189 return room !== undefined && drive(room, id, bytes);
190 },
191 advance(now) {
192 // Every 64 Hz step that has fallen due since the last call, capped: a
193 // process that was descheduled for a second does not then burn a second of
194 // CPU catching up on every room at once. Past the cap the rooms' clocks
195 // simply run slow for that moment, which is a hitch and not a hole.
196 const due = Math.min(Math.floor((now - steppedAt) / (TICK * 1000)), MAX_CATCHUP);
197 if (due > 0) steppedAt += due * TICK * 1000;
198 const interval = 1000 / SNAPSHOT_HZ;
199 for (const [code, room] of rooms) {
200 for (let i = 0; i < due; i++) stepRoom(room);
201 if (room.members.size > 0) room.usedAt = now;
202 const last = sentAt.get(code) ?? now;
203 if (now - last >= interval) {
204 sentAt.set(code, now);
205 const log = logSince(room);
206 const words = log === null ? null : JSON.stringify(log);
207 for (const member of room.members.values()) {
208 send(room, member);
209 if (words !== null) wire.deliver(member.id, words);
210 }
211 }
212 }
213 // A member nobody has heard from is holding a seat somebody else could
214 // have: closed here, and the close is what takes them out of the room.
215 for (const [id, seated] of where) {
216 if (now - seated.heardAt > SILENT_MS) wire.close(id, CLOSE_SILENT, "no input for thirty seconds");
217 }
218 for (const [code, room] of rooms) {
219 if (isIdle(room, now, IDLE_MS)) {
220 rooms.delete(code);
221 sentAt.delete(code);
222 }
223 }
224 },
225 members: () => where.size,
226 lines: (now) => [...rooms.values()].map((room) => describeRoom(room, now)),
227 };
228 return hub;
140 const hub = createEngineHub(wire, SHOOTER, {
141 now: options.now,
142 limiter: options.limiter ?? createLimiter(FRAME_LIMIT),
143 rooms: ROOM_CAP,
144 idleMs: IDLE_MS,
145 silentMs: SILENT_MS,
146 messageMax: MESSAGE_MAX,
147 maxCatchup: MAX_CATCHUP,
148 closeTooBig: CLOSE_TOO_BIG,
149 closeFlood: CLOSE_FLOOD,
150 closeSilent: CLOSE_SILENT,
151 });
152 return { ...hub, open: (o) => hub.open(o, o.now) };
229153 }
230
src/rooms/stage.test-helper.ts +3 −3
@@ -1,5 +1,5 @@
11 /**
2 * The stage a room is played on in a test: the fixtures the four test files
2 * The stage a room is played on in a test: the fixtures the three test files
33 * beside this one would otherwise each keep a copy of.
44 *
55 * Both games do it this way — `src/client/stage.test-helper.ts` and
@@ -11,8 +11,8 @@
1111 * sends for one tick of holding W, and a `Wire` that remembers what it was
1212 * handed instead of sending it.
1313 *
14 * It ships in no bundle: only `code.test.ts`, `wire.test.ts`, `room.test.ts`
15 * and `hub.test.ts` import it.
14 * It ships in no bundle: only `wire.test.ts`, `room.test.ts` and `hub.test.ts`
15 * import it.
1616 */
1717
1818 import { DE_FORGE } from "../client/map.ts";
src/server.ts +1 −1
@@ -69,7 +69,7 @@ import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
6969 import { boardsHtml, rangePage } from "./pages/range.ts";
7070 import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapons.ts";
7171 import { policyFor } from "./policy.ts";
72 import { roomCodeFrom } from "./rooms/code.ts";
72 import { roomCodeFrom } from "./engine/index.ts";
7373 import { createHub, CLOSE_GONE, MESSAGE_MAX, ROOM_CAP } from "./rooms/hub.ts";
7474 import { playedOn } from "./mapname.ts";
7575 import { styleFor, type Linked } from "./pages/shell.ts";

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs