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

forked from trevato/cs

A member of a room is the engine's: the socket, the clock and the four sentences stop being the shooter's #370

Merged opened this

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

Merged

torque/cs-engine:torque/334-a-member-of-a-room-is-the-engine-s-the-s → main 1 commit fork

  • Merged. torque/334-a-member-of-a-room-is-the-engine-s-the-s was merged into main as 8281e49.

Review

Sign in to review this change.
6 files changed +589 −71
src/client/session.ts +56 −70
@@ -19,24 +19,24 @@
1919 * still in between. Interpolating them is its own issue and is not pretended to
2020 * here or anywhere else.
2121 *
22 * The socket, its lifecycle, the clock since the last snapshot, the count of
23 * ticks sent and the rule for which sentence is said are not the shooter's and
24 * are `createMember` behind the engine's door. What is left here is this game's
25 * half: the codec out of `src/rooms/wire.ts`, the world a `hello` builds with
26 * `me` on our seat, the roster onto bodies, the feed and messages with their
27 * caps, our own body off a snapshot, and the four sentences in its words.
28 *
2229 * Nothing in here is a browser: `connect` is handed in, so `session.test.ts`
2330 * plays a whole room through a socket of plain objects, on a clock it moves.
2431 */
2532
26 import type { Hello, Pressed } from "../rooms/wire.ts";
33 import { createMember, type MemberSocket } from "../engine/index.ts";
34 import type { Down, Hello, Pressed, Snapshot } from "../rooms/wire.ts";
2735 import { applySnapshot, forget, readDown, writeInput, SNAPSHOT_FRAME } from "../rooms/wire.ts";
2836 import type { Player, World } from "./world.ts";
2937
30 /** All this file asks a WebSocket to be. A browser's is one of these. */
31 export type Socket = {
32 binaryType: string;
33 send(data: Uint8Array): void;
34 close(): void;
35 onopen: (() => void) | null;
36 onmessage: ((event: { data: unknown }) => void) | null;
37 onclose: (() => void) | null;
38 onerror: (() => void) | null;
39 };
38 /** All this file asks a WebSocket to be — the engine's, under the name this game has always used. */
39 export type Socket = MemberSocket;
4040
4141 /** What the page shows about a room while it is up. */
4242 export type Standing = {
@@ -72,6 +72,12 @@ export const DROPPED = "The room closed the connection. Leave the match and join
7272 /** How far behind the last snapshot has to be before the page says so, in ms. */
7373 export const BEHIND_MS = 500;
7474
75 /**
76 * The reused input buffer: two bytes of headroom over `INPUT_BYTES`, as it has
77 * always been, since `writeInput` hands back only what it wrote.
78 */
79 const INPUT_BUFFER = 16;
80
7581 export function createSession(options: {
7682 connect(url: string): Socket;
7783 /** Where to connect: the socket's path with the room and the name on it. */
@@ -86,29 +92,23 @@ export function createSession(options: {
8692 build(hello: Hello): World;
8793 now(): number;
8894 }): Session {
89 const socket = options.connect(options.url);
90 socket.binaryType = "arraybuffer";
91 let world: World | null = null;
9295 let seat = -1;
93 let live = false;
94 let closed = false;
95 let landed = -1;
9696 let people = 0;
97 /** What the next input frame is stamped with: our own count of ticks sent. */
98 let sent = 0;
99 const frame = new Uint8Array(16);
10097
101 socket.onopen = (): void => void (live = true);
102 socket.onclose = (): void => {
103 live = false;
104 closed = true;
105 };
106 socket.onerror = (): void => void (live = false);
107 socket.onmessage = (event): void => {
108 const data = event.data;
109 if (typeof data === "string") {
110 const down = readDown(data);
111 if (down === null) return;
98 const member = createMember<World, Down, Omit<Pressed, "tick">, Snapshot>({
99 connect: options.connect,
100 url: options.url,
101 now: options.now,
102 codec: {
103 snapshot: SNAPSHOT_FRAME,
104 inputBytes: INPUT_BUFFER,
105 readText: readDown,
106 // A world only ever exists with a seat, so `seat` is ours by the time a
107 // snapshot is read.
108 readSnapshot: (world, bytes) => applySnapshot(world, bytes, seat),
109 writeInput: (pressed, tick, into) => writeInput({ ...pressed, tick }, into),
110 },
111 hear(down, world) {
112112 if (down.k === "hello") {
113113 seat = down.seat;
114114 const built = options.build(down);
@@ -117,36 +117,35 @@ export function createSession(options: {
117117 built.me = (built.players[seat] ?? built.me) as Player;
118118 built.me.bot = false;
119119 forget(built);
120 world = built;
121 apply(down.roster);
122 } else if (down.k === "roster") {
123 apply(down.roster);
124 } else if (world !== null) {
120 apply(built, down.roster);
121 return built;
122 }
123 if (world === null) return null;
124 if (down.k === "roster") {
125 apply(world, down.roster);
126 } else {
125127 // The words of the round, which are written inside the server's own tick.
126128 for (const entry of down.feed) world.feed.push({ ...entry, weapon: entry.weapon as never });
127129 for (const line of down.say) world.messages.push(line);
128130 if (world.feed.length > 8) world.feed.splice(0, world.feed.length - 8);
129131 if (world.messages.length > 8) world.messages.splice(0, world.messages.length - 8);
130132 }
131 return;
132 }
133 if (world === null || seat < 0) return;
134 const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
135 if (bytes === null || bytes[0] !== SNAPSHOT_FRAME) return;
136 const got = applySnapshot(world, bytes, seat);
137 if (got === null) return;
138 landed = options.now();
133 return world;
134 },
139135 // Our own body: the server's position, handed over rather than written in
140136 // (`wire.ts`), so this is the one place a page decides what to do with it.
141 if (got.own !== null) {
142 world.me.pos = got.own.pos;
143 world.me.vel = got.own.vel;
144 }
145 };
137 landed(got, world) {
138 if (got.own !== null) {
139 world.me.pos = got.own.pos;
140 world.me.vel = got.own.vel;
141 }
142 },
143 sentences: { joining: CONNECTING, playing: PLAYING, behind: BEHIND, dropped: DROPPED },
144 behindMs: BEHIND_MS,
145 });
146146
147147 /** Names and bots off the roster, onto the bodies they belong to. */
148 function apply(roster: Hello["roster"]): void {
149 if (world === null) return;
148 function apply(world: World, roster: Hello["roster"]): void {
150149 people = 0;
151150 for (const seated of roster) {
152151 const body = world.players[seated.seat];
@@ -160,25 +159,12 @@ export function createSession(options: {
160159 }
161160
162161 return {
163 world: () => world,
162 world: member.world,
164163 seat: () => seat,
165 standing(now) {
166 const behind = landed < 0 ? -1 : now - landed;
167 const line =
168 closed ? DROPPED :
169 !live || landed < 0 ? CONNECTING :
170 behind > BEHIND_MS ? BEHIND : PLAYING;
171 return { code: options.code, live, behind, line, people };
172 },
173 press(pressed) {
174 if (!live) return;
175 sent += 1;
176 socket.send(writeInput({ ...pressed, tick: sent }, frame));
177 },
178 close() {
179 closed = true;
180 live = false;
181 socket.close();
182 },
164 standing: (now) => ({
165 code: options.code, live: member.live(), behind: member.behind(now), line: member.line(now), people,
166 }),
167 press: member.press,
168 close: member.close,
183169 };
184170 }
src/engine/GUIDE.md +41
@@ -793,6 +793,47 @@ a room — the query on a socket upgrade and the field in a menu — go through
793793 neither trusting the other. A code is never a path and never reaches markup
794794 unescaped.
795795
796 ### member
797
798 **Weight**: 0.9 kB raw, 0.6 kB gzipped — this module alone.
799
800 The other end of a room: a page's member of it. Every game played with
801 somebody else writes the same half-page under its protocol — a socket asked for
802 bytes, open, errored and closed, a count of the ticks it sent, a buffer it
803 reuses for them, the time since the last frame landed, and a sentence for the
804 player about all of that — and none of it knows what the game is, so it is
805 here. `createMember` is handed a `MemberOptions` and hands back a `Member`.
806
807 **The seam is the codec, and it is plain functions.** A `MemberCodec` is the
808 game's wire: the byte a snapshot starts with, how big the input buffer is, a
809 line of text into a control message, a snapshot's bytes into the world, and one
810 tick of input written into the buffer. Beside it the game hands in what a
811 control message does — `MemberOptions.hear` is given the message and the world
812 so far and gives back the world to fill from then on, so a greeting builds one
813 and the rest keep the one they had — and, optionally, what to do with whatever
814 a snapshot handed back. The shooter's snapshot hands back its own body's
815 position, which the page reconciles against rather than takes; the member
816 passes it on untouched and decides nothing about it. `MemberOptions.connect` and the clock are
817 handed in too, so the module touches no global and a test plays a room through
818 a `MemberSocket` of plain objects.
819
820 **The four sentences are the game's text under the engine's rule.** A
821 `Sentences` is four strings — joining, playing, behind and dropped — and
822 `MemberOptions.behindMs` is the threshold; neither has a default here, for the
823 same reason the hub's caps do not. The rule is the engine's: dropped once the
824 socket has closed, from either end, over everything else; joining until the
825 socket is open and a snapshot has landed; behind when more than the threshold
826 has passed since the last one — exactly the threshold is not; and playing
827 otherwise. `Member.line` says it, and `Member.live`, `Member.closed`,
828 `Member.behind` and `Member.sent` are the readings under it.
829
830 **Every refusal is a drop, never a throw.** A line the codec does not read, a
831 binary frame whose first byte is not the snapshot's, a snapshot while there is
832 no world, a snapshot the codec refuses — which leaves the clock where it was —
833 data that is neither a string, an array buffer nor a `Uint8Array`, anything the
834 socket delivers after it closed, and a `Member.press` while it is not open,
835 which is not counted as a tick either.
836
796837 ### renderer
797838
798839 **Weight**: 9.1 kB raw, 3.7 kB gzipped — including math and pixels, which come with it.
src/engine/index.ts +11
@@ -121,6 +121,17 @@ export { Cursor, finite, packAngle, unpackAngle, unpackSigned } from "./frame.ts
121121 export { createHub, newRoomCode, roomCodeFrom } from "./rooms.ts";
122122 export type { Hub, HubOptions, HubRoom, RoomGame, Wire } from "./rooms.ts";
123123
124 /**
125 * The other end of a room: a page's member of it. One socket asked for bytes,
126 * its open, errored and closed lifecycle, the world it fills, the milliseconds
127 * since the last snapshot landed, the ticks of input sent, and which of four
128 * sentences the connection is. The game hands in its codec as plain functions,
129 * the byte a snapshot starts with, what a control message does to its world,
130 * and its own sentences and threshold — none of which has a default here.
131 */
132 export { createMember } from "./member.ts";
133 export type { Member, MemberCodec, MemberOptions, MemberSocket, Sentences } from "./member.ts";
134
124135 /** The WebGL2 draw: an atlas and named meshes in, a frame of instances out. */
125136 export { createRenderer } from "./renderer.ts";
126137 export type { Renderer, RendererAssets } from "./renderer.ts";
src/engine/member.test.ts added +306
@@ -0,0 +1,306 @@
1 /**
2 * A room of a game that is not the shooter, played through `createMember` with
3 * no browser: a counter of plain objects. Its hello names how many cells the
4 * world has, a snapshot is the kind byte `SNAP` and one byte per cell, and a
5 * tick of input is three bytes — the kind, the tick and the number pressed.
6 * The socket is four callbacks and a `send` that keeps a copy, and the clock is
7 * a number the test moves, the way `page.test.ts` and `rooms.test.ts` do it.
8 *
9 * The clock's arithmetic is written out where it is used: `behind` is
10 * `now - landed` in milliseconds, and this game's `behindMs` is 100, so 100 is
11 * still playing and 101 is behind.
12 */
13
14 import { expect, test } from "bun:test";
15 import { createMember, type Member, type MemberSocket, type Sentences } from "./index.ts";
16
17 type Counter = { cells: number[]; names: string[]; greeted: number };
18 type Said = { k: "hello"; cells: number } | { k: "names"; names: string[] };
19
20 const SNAP = 7;
21 const INPUT = 9;
22 const BEHIND_MS = 100;
23 const WORDS: Sentences = { joining: "wait", playing: "go", behind: "slow", dropped: "gone" };
24
25 /** The counter's text frames, read the way a game reads what it does not trust. */
26 function readText(text: string): Said | null {
27 let v: unknown;
28 try {
29 v = JSON.parse(text);
30 } catch {
31 return null;
32 }
33 if (typeof v !== "object" || v === null) return null;
34 const m = v as Record<string, unknown>;
35 if (m.k === "hello" && Number.isInteger(m.cells) && (m.cells as number) > 0 && (m.cells as number) <= 8)
36 return { k: "hello", cells: m.cells as number };
37 if (m.k === "names" && Array.isArray(m.names) && m.names.every((n) => typeof n === "string"))
38 return { k: "names", names: m.names as string[] };
39 return null;
40 }
41
42 /** A snapshot is the kind and one byte a cell; anything shorter is refused whole. */
43 function readSnapshot(world: Counter, bytes: Uint8Array): { sum: number } | null {
44 if (bytes.byteLength !== world.cells.length + 1) return null;
45 let sum = 0;
46 for (let i = 0; i < world.cells.length; i++) {
47 world.cells[i] = bytes[i + 1] as number;
48 sum += world.cells[i] as number;
49 }
50 return { sum };
51 }
52
53 type Wire = {
54 socket: MemberSocket;
55 sent: Uint8Array[];
56 closes: number;
57 urls: string[];
58 say(data: unknown): void;
59 };
60
61 function wire(): Wire {
62 const w: Wire = {
63 socket: {
64 binaryType: "",
65 send: (data) => void w.sent.push(new Uint8Array(data)),
66 close: () => void (w.closes += 1),
67 onopen: null, onmessage: null, onclose: null, onerror: null,
68 },
69 sent: [],
70 closes: 0,
71 urls: [],
72 // Deliberately delivers after a close too: the member is what refuses.
73 say: (data) => w.socket.onmessage?.({ data }),
74 };
75 return w;
76 }
77
78 type Stage = Wire & { member: Member<Counter, number>; clock: number; heard: Said[]; sums: number[] };
79
80 function stage(): Stage {
81 const w = wire();
82 const heard: Said[] = [];
83 const sums: number[] = [];
84 let clock = 1000;
85 const member = createMember<Counter, Said, number, { sum: number }>({
86 connect: (url) => {
87 w.urls.push(url);
88 return w.socket;
89 },
90 url: "/ws?room=ABCDEF",
91 now: () => clock,
92 codec: {
93 snapshot: SNAP,
94 inputBytes: 4,
95 readText,
96 readSnapshot,
97 writeInput: (n, tick, into) => {
98 into[0] = INPUT;
99 into[1] = tick;
100 into[2] = n;
101 return into.subarray(0, 3);
102 },
103 },
104 hear(said, world) {
105 heard.push(said);
106 if (said.k === "hello")
107 return { cells: new Array(said.cells).fill(0), names: [], greeted: (world?.greeted ?? 0) + 1 };
108 if (world !== null) world.names = said.names;
109 return world;
110 },
111 landed: (got) => void sums.push(got.sum),
112 sentences: WORDS,
113 behindMs: BEHIND_MS,
114 });
115 // Defined rather than assigned: `Object.assign` would read the getter once
116 // and copy the number, and a test moving the clock would move nothing.
117 return Object.defineProperties(w, {
118 member: { value: member },
119 heard: { value: heard },
120 sums: { value: sums },
121 clock: { get: () => clock, set: (ms: number) => void (clock = ms) },
122 }) as Stage;
123 }
124
125 const open = (s: Stage): void => s.socket.onopen?.();
126 const hello = (cells = 3): string => JSON.stringify({ k: "hello", cells });
127 const snap = (...cells: number[]): Uint8Array => Uint8Array.from([SNAP, ...cells]);
128
129 test("a member connects where it was told and asks for bytes", () => {
130 const s = stage();
131 expect(s.urls).toEqual(["/ws?room=ABCDEF"]);
132 expect(s.socket.binaryType).toBe("arraybuffer");
133 expect(s.member.world()).toBeNull();
134 expect(s.member.live()).toBe(false);
135 expect(s.member.closed()).toBe(false);
136 expect(s.member.behind(1000)).toBe(-1);
137 expect(s.member.sent()).toBe(0);
138 expect(s.member.line(1000)).toBe(WORDS.joining);
139 });
140
141 test("a whole room of a counter: a hello, the names, snapshots, and a drop", () => {
142 const s = stage();
143 open(s);
144 expect(s.member.live()).toBe(true);
145 // Names before a hello reach the game with no world, and the game keeps none.
146 s.say(JSON.stringify({ k: "names", names: ["early"] }));
147 expect(s.member.world()).toBeNull();
148 s.say(hello(3));
149 const world = s.member.world() as Counter;
150 expect(world.cells).toEqual([0, 0, 0]);
151 // A hello is not a snapshot: still joining.
152 expect(s.member.line(1000)).toBe(WORDS.joining);
153 s.say(JSON.stringify({ k: "names", names: ["ada", "bo"] }));
154 expect(world.names).toEqual(["ada", "bo"]);
155
156 s.clock = 2000;
157 s.say(snap(1, 2, 3));
158 expect(world.cells).toEqual([1, 2, 3]);
159 // What the codec handed back is the game's, passed on untouched.
160 expect(s.sums).toEqual([6]);
161 expect(s.member.behind(2000)).toBe(0);
162 expect(s.member.line(2000)).toBe(WORDS.playing);
163 expect(s.member.line(2000 + BEHIND_MS)).toBe(WORDS.playing);
164 expect(s.member.behind(2000 + BEHIND_MS + 1)).toBe(101);
165 expect(s.member.line(2000 + BEHIND_MS + 1)).toBe(WORDS.behind);
166
167 // An ArrayBuffer is the same frame, because a browser hands over one.
168 s.clock = 2500;
169 s.say(snap(4, 5, 6).slice().buffer);
170 expect(world.cells).toEqual([4, 5, 6]);
171 expect(s.member.line(2500)).toBe(WORDS.playing);
172
173 s.member.press(1);
174 s.member.press(2);
175 expect(s.sent.map((b) => [...b])).toEqual([[INPUT, 1, 1], [INPUT, 2, 2]]);
176 expect(s.member.sent()).toBe(2);
177
178 // The room hung up: the last sentence wins over every other, for good.
179 s.socket.onclose?.();
180 expect(s.member.live()).toBe(false);
181 expect(s.member.closed()).toBe(true);
182 expect(s.member.line(2500)).toBe(WORDS.dropped);
183 s.socket.onopen?.();
184 expect(s.member.live()).toBe(false);
185 expect(s.member.line(2500)).toBe(WORDS.dropped);
186 });
187
188 test("a second hello is the game's to decide, and it rebuilds here", () => {
189 const s = stage();
190 open(s);
191 s.say(hello(2));
192 s.say(hello(4));
193 expect((s.member.world() as Counter).cells).toHaveLength(4);
194 expect((s.member.world() as Counter).greeted).toBe(2);
195 });
196
197 /** A seated member with one snapshot landed at 2000, for the refusals to spoil. */
198 function seated(): { s: Stage; world: Counter; was: string } {
199 const s = stage();
200 open(s);
201 s.say(hello(3));
202 s.clock = 2000;
203 s.say(snap(1, 2, 3));
204 const world = s.member.world() as Counter;
205 return { s, world, was: JSON.stringify(world) };
206 }
207
208 test("a text frame the codec does not read changes nothing", () => {
209 const { s, world, was } = seated();
210 const heard = s.heard.length;
211 for (const text of ["", "{not json", "null", "[1]", JSON.stringify({ k: "hello", cells: 99 })])
212 expect(() => s.say(text)).not.toThrow();
213 expect(s.heard).toHaveLength(heard);
214 expect(s.member.world()).toBe(world);
215 expect(JSON.stringify(world)).toBe(was);
216 });
217
218 test("a binary frame whose first byte is not the snapshot kind changes nothing", () => {
219 const { s, world, was } = seated();
220 s.clock = 3000;
221 expect(() => s.say(Uint8Array.from([SNAP + 1, 9, 9, 9]))).not.toThrow();
222 expect(() => s.say(new Uint8Array(0))).not.toThrow();
223 expect(JSON.stringify(world)).toBe(was);
224 expect(s.sums).toEqual([6]);
225 expect(s.member.behind(3000)).toBe(1000);
226 });
227
228 test("a snapshot the codec refuses changes nothing and does not stamp the clock", () => {
229 const { s, world, was } = seated();
230 s.clock = 3000;
231 s.say(snap(9, 9));
232 expect(JSON.stringify(world)).toBe(was);
233 expect(s.member.behind(3000)).toBe(1000);
234 expect(s.member.line(3000)).toBe(WORDS.behind);
235 });
236
237 test("a frame before a hello is dropped, and the one after it is not", () => {
238 const s = stage();
239 open(s);
240 expect(() => s.say(snap(1, 2, 3))).not.toThrow();
241 expect(s.member.world()).toBeNull();
242 expect(s.sums).toEqual([]);
243 expect(s.member.behind(1000)).toBe(-1);
244 s.say(hello(3));
245 s.say(snap(1, 2, 3));
246 expect(s.sums).toEqual([6]);
247 });
248
249 test("a frame when the game kept no world is dropped", () => {
250 const s = stage();
251 open(s);
252 // Names with no hello: the game hands back null, so there is still no world.
253 s.say(JSON.stringify({ k: "names", names: ["x"] }));
254 s.say(snap(1));
255 expect(s.sums).toEqual([]);
256 expect(s.member.line(1000)).toBe(WORDS.joining);
257 });
258
259 test("data that is neither a string, an ArrayBuffer nor a Uint8Array is dropped", () => {
260 const { s, world, was } = seated();
261 for (const data of [42, null, undefined, true, {}, [SNAP, 1, 2, 3], new Date(0), new Uint16Array([SNAP, 1, 2])])
262 expect(() => s.say(data)).not.toThrow();
263 expect(JSON.stringify(world)).toBe(was);
264 expect(s.sums).toEqual([6]);
265 });
266
267 test("nothing a socket delivers after close reaches the world", () => {
268 const { s, world, was } = seated();
269 s.member.close();
270 expect(s.closes).toBe(1);
271 expect(s.member.closed()).toBe(true);
272 expect(s.member.line(2000)).toBe(WORDS.dropped);
273 s.say(snap(7, 7, 7));
274 s.say(hello(5));
275 expect(s.member.world()).toBe(world);
276 expect(JSON.stringify(world)).toBe(was);
277 });
278
279 test("a press while the socket is not open goes nowhere and is not counted", () => {
280 const s = stage();
281 s.member.press(5);
282 expect(s.sent).toEqual([]);
283 open(s);
284 s.member.press(5);
285 expect(s.sent.map((b) => [...b])).toEqual([[INPUT, 1, 5]]);
286 // An error is a connection that is no longer live, though not a closed one.
287 s.socket.onerror?.();
288 expect(s.member.live()).toBe(false);
289 expect(s.member.closed()).toBe(false);
290 s.member.press(6);
291 s.member.close();
292 s.member.press(7);
293 expect(s.sent).toHaveLength(1);
294 expect(s.member.sent()).toBe(1);
295 });
296
297 test("one buffer is reused for every tick, at the size the game asked for", () => {
298 const s = stage();
299 open(s);
300 const seen: ArrayBufferLike[] = [];
301 s.socket.send = (data) => void seen.push(data.buffer);
302 s.member.press(1);
303 s.member.press(2);
304 expect(seen[0]).toBe(seen[1] as ArrayBufferLike);
305 expect(seen[0]?.byteLength).toBe(4);
306 });
src/engine/member.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * The page's end of a room of *a* game: one socket, the world it fills, a clock
3 * since the last frame landed, a count of ticks sent, and the sentence a page
4 * shows about the connection. The other end is `rooms.ts`.
5 *
6 * Nothing in here knows a world, a seat, a hello or a snapshot's layout. The
7 * game hands in its codec as plain functions — a line of text into a control
8 * message, a snapshot's bytes into its world, one tick of input into bytes —
9 * and says which first byte a snapshot starts with. What a control message
10 * does is the game's too: `hear` is handed the message and the world so far and
11 * hands back the world the member fills from then on, so a greeting builds one
12 * and everything else keeps the one it had. What a snapshot hands back — the
13 * shooter's own body, which its page reconciles against rather than takes — is
14 * passed to `landed` untouched: the member decides nothing about it.
15 *
16 * **The sentences and the threshold are the game's text under the engine's
17 * rule.** There is no default: a game hands in its four `Sentences` and the
18 * milliseconds `behindMs` past which the connection is behind, and this file
19 * decides only which one is said — dropped once closed, joining until the
20 * socket is open and a frame has landed, behind once more than `behindMs` has
21 * passed since the last one, and in the room otherwise. `member.test.ts` reads
22 * that rule with a fake game's words.
23 *
24 * Every refusal is here, and none of them throws: a line the codec does not
25 * read, a binary frame whose first byte is not the snapshot kind, a snapshot
26 * before there is a world, a snapshot the codec refuses (which also leaves the
27 * clock where it was), data that is neither a string, an `ArrayBuffer` nor a
28 * `Uint8Array`, anything at all after the socket closed, and a `press` while
29 * the socket is not open — which is not counted as a tick either.
30 *
31 * Nothing in here is a browser: `connect` is handed in and so is the clock, so
32 * a whole room plays through a socket of plain objects on a number a test moves.
33 */
34
35 /** All a member asks a WebSocket to be. A browser's is one of these. */
36 export type MemberSocket = {
37 binaryType: string;
38 send(data: Uint8Array): void;
39 close(): void;
40 onopen: (() => void) | null;
41 onmessage: ((event: { data: unknown }) => void) | null;
42 onclose: (() => void) | null;
43 onerror: (() => void) | null;
44 };
45
46 /** The four things a page can say about its connection, in the game's words. */
47 export type Sentences = {
48 /** The socket is not open yet, or no frame has landed since it was. */
49 joining: string;
50 /** Frames are landing. */
51 playing: string;
52 /** The last frame landed more than `behindMs` ago. */
53 behind: string;
54 /** The socket closed, from either end. Said over every other. */
55 dropped: string;
56 };
57
58 /**
59 * The game's wire, as plain functions. `W` is its world, `M` a control
60 * message, `P` one tick of input without its tick, `R` what a snapshot hands
61 * back to the page.
62 */
63 export type MemberCodec<W, M, P, R> = {
64 /** The first byte of a snapshot. A binary frame that starts otherwise is dropped. */
65 snapshot: number;
66 /** How many bytes the one reused input buffer is. */
67 inputBytes: number;
68 /** A text frame into a control message, or null when it is not one. */
69 readText(text: string): M | null;
70 /** A snapshot's bytes into the world, or null — changing nothing — when they are not one. */
71 readSnapshot(world: W, bytes: Uint8Array): R | null;
72 /** One tick of input written into `into`, stamped `tick`: the bytes to send. */
73 writeInput(pressed: P, tick: number, into: Uint8Array<ArrayBuffer>): Uint8Array;
74 };
75
76 export type MemberOptions<W, M, P, R> = {
77 connect(url: string): MemberSocket;
78 url: string;
79 /** Milliseconds on any clock that only moves forward: `performance.now()` in a page. */
80 now(): number;
81 codec: MemberCodec<W, M, P, R>;
82 /** A control message arrived: the world to fill from now on, given the one so far (null before). */
83 hear(message: M, world: W | null): W | null;
84 /** A snapshot was read into the world: what the codec handed back, for the game to use. */
85 landed?(got: R, world: W): void;
86 sentences: Sentences;
87 /** How long since the last frame before the connection is behind, in ms. Exactly this much is not. */
88 behindMs: number;
89 };
90
91 export type Member<W, P> = {
92 /** The world the frames are filling, or null until a control message builds one. */
93 world(): W | null;
94 /** Whether the socket is open right now. */
95 live(): boolean;
96 /** Whether the socket has closed, from either end. It does not reopen. */
97 closed(): boolean;
98 /** Milliseconds since the last snapshot landed, or −1 before the first. */
99 behind(now: number): number;
100 /** How many ticks of input have gone upstream: the last one's stamp. */
101 sent(): number;
102 /** The one sentence for the connection at `now`. */
103 line(now: number): string;
104 /** One tick of input upstream. Dropped, and not counted, while the socket is not open. */
105 press(pressed: P): void;
106 close(): void;
107 };
108
109 export function createMember<W, M, P, R>(options: MemberOptions<W, M, P, R>): Member<W, P> {
110 const { codec, sentences } = options;
111 const socket = options.connect(options.url);
112 // Without this a browser hands `onmessage` a Blob, which nothing here reads.
113 socket.binaryType = "arraybuffer";
114 let world: W | null = null;
115 let live = false;
116 let closed = false;
117 let landed = -1;
118 let sent = 0;
119 const frame = new Uint8Array(codec.inputBytes);
120
121 socket.onopen = (): void => void (live = !closed);
122 socket.onclose = (): void => {
123 live = false;
124 closed = true;
125 };
126 socket.onerror = (): void => void (live = false);
127 socket.onmessage = (event): void => {
128 if (closed) return;
129 const data = event.data;
130 if (typeof data === "string") {
131 const message = codec.readText(data);
132 if (message !== null) world = options.hear(message, world);
133 return;
134 }
135 if (world === null) return;
136 const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
137 if (bytes === null || bytes[0] !== codec.snapshot) return;
138 const got = codec.readSnapshot(world, bytes);
139 if (got === null) return;
140 landed = options.now();
141 options.landed?.(got, world);
142 };
143
144 const behind = (now: number): number => (landed < 0 ? -1 : now - landed);
145
146 return {
147 world: () => world,
148 live: () => live,
149 closed: () => closed,
150 behind,
151 sent: () => sent,
152 line(now) {
153 if (closed) return sentences.dropped;
154 if (!live || landed < 0) return sentences.joining;
155 return behind(now) > options.behindMs ? sentences.behind : sentences.playing;
156 },
157 press(pressed) {
158 if (!live) return;
159 sent += 1;
160 socket.send(codec.writeInput(pressed, sent, frame));
161 },
162 close() {
163 closed = true;
164 live = false;
165 socket.close();
166 },
167 };
168 }
src/engine/surface.json +7 −1
@@ -1,5 +1,5 @@
11 {
2 "version": "0.9.0",
2 "version": "0.10.0",
33 "names": [
44 "Activations",
55 "Adam",
@@ -54,6 +54,10 @@
5454 "LoopOptions",
5555 "MAX_RATIO",
5656 "Mat4",
57 "Member",
58 "MemberCodec",
59 "MemberOptions",
60 "MemberSocket",
5761 "Mesh",
5862 "MouseEventish",
5963 "MoveTuning",
@@ -74,6 +78,7 @@
7478 "Scene",
7579 "SceneCapacity",
7680 "Screen",
81 "Sentences",
7782 "Shader",
7883 "Shadow",
7984 "Shape",
@@ -115,6 +120,7 @@
115120 "createFullscreen",
116121 "createHub",
117122 "createLoop",
123 "createMember",
118124 "createPolicy",
119125 "createRenderer",
120126 "createRollout",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs