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

The round, as state and one `tick`: no timer, no database, no socket in it #64

Merged opened this

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

Merged

vesper/drift:vesper/58-the-round-as-state-and-one-tick-no-timer → main 3 commits fork 1 thread · 1 unresolved

  • Merged. vesper/58-the-round-as-state-and-one-tick-no-timer was merged into main as e229cce.

Review

Sign in to review this change.
3 files changed +888 −3
README.md +13 −3
@@ -60,9 +60,18 @@ yet. `src/world.ts` advances one tick — motes, momentum, the current field,
6060 the rim and the gate — with no clock, no database and no network, and the
6161 same world always yields the same next one because every source of chance is
6262 the seed in the state. `bun test` drives it by hand and proves every
63 invariant the record states about the sea, each named by its number. The loop that
64 calls `step` twenty times a second and the socket that carries the frames are
65 the issues after this one.
63 invariant the record states about the sea, each named by its number.
64
65 The round is the same kind of thing one level up. `src/round.ts` is the
66 clock, the end condition, the quiet between rounds, the next world and the
67 seat bookkeeping — plain data and one `tick(round)` that returns the next
68 round and a list of **effects**: `started`, `ended` with its outcome, and a
69 `frame` every second tick. It holds no timer, no database and no socket, so
70 its whole behaviour is reached by calling a function: 3600 calls is exactly
71 one round, a bloom put through the gate ends it sooner, and one seed replays
72 to the same state, gate after gate. What an effect *means* — a row, an
73 emit, bytes on a socket — belongs to the loop that spends them, which is the
74 issue after this one.
6675
6776 ## The game
6877
@@ -89,6 +98,7 @@ decide nothing themselves. `AGENTS.md` says how to add to it.
8998 | ---------------------------------- | ----------------------------------------------------------------- |
9099 | `src/actions.ts` | every operation, and who may: players, rounds, the ladder |
91100 | `src/world.ts` | the simulation: motes, momentum and the current field |
101 | `src/round.ts` | the round: its clock, its end, its seats, and effects as data |
92102 | `src/protocol.ts` | the wire: one frame down, one steer up, quantised and sized |
93103 | `src/who.ts` | who is asking, from the forge's signed headers |
94104 | `src/session.ts` | the guest key in a cookie, and the player id it derives |
src/round.test.ts added +414
@@ -0,0 +1,414 @@
1 /**
2 * The round's arithmetic, driven by hand. Nothing here sleeps, nothing sets
3 * a timer and nothing opens a database: every rule in `src/round.ts` is
4 * reached by calling `tick` a known number of times, which is the property
5 * the module is built for — a round that can only be tested by waiting is a
6 * round nobody tests.
7 *
8 * The numbers come from the record (`src/concept.ts`, `docs/wake.md`) rather
9 * than being spelled again here, so a tuning pass moves them in one place.
10 */
11
12 import { expect, test } from "bun:test";
13 import {
14 IDLE_SECONDS,
15 INTERMISSION_SECONDS,
16 ROSTER_MAX,
17 ROUND_SECONDS,
18 UNSTEERED_SECONDS,
19 } from "./concept.ts";
20 import {
21 decodeFrame,
22 encodeFrame,
23 MOTE_STRIDE,
24 POSITION_SCALE,
25 VELOCITY_SCALE,
26 type Frame,
27 } from "./protocol.ts";
28 import {
29 FRAME_EVERY,
30 IDLE_TICKS,
31 INTERMISSION_TICKS,
32 ROUND_TICKS,
33 UNSTEERED_TICKS,
34 elapsedSeconds,
35 frameOf,
36 open,
37 releaseSeat,
38 remainingSeconds,
39 steer,
40 takeSeat,
41 tick,
42 type Effect,
43 type Round,
44 } from "./round.ts";
45 import {
46 ARENA_RADIUS,
47 DT,
48 TICK_HZ,
49 fieldMagnitude,
50 moteById,
51 withinGate,
52 type World,
53 } from "./world.ts";
54
55 const STARTED_AT = "2026-09-20T00:00:00.000Z";
56
57 /** Drive `n` ticks and hand back the round it left and everything it said. */
58 function run(round: Round, n: number): { round: Round; effects: Effect[] } {
59 const effects: Effect[] = [];
60 let at = round;
61 for (let i = 0; i < n; i++) {
62 const out = tick(at);
63 at = out.round;
64 effects.push(...out.effects);
65 }
66 return { round: at, effects };
67 }
68
69 const kinds = (effects: readonly Effect[], kind: Effect["kind"]): Effect[] =>
70 effects.filter((e) => e.kind === kind);
71
72 /** Put the bloom on the rim inside the gate, running outward through it. */
73 function aimBloomAtGate(world: World): void {
74 const a = world.gate.angle;
75 world.bloom.x = Math.cos(a) * (ARENA_RADIUS - 1);
76 world.bloom.y = Math.sin(a) * (ARENA_RADIUS - 1);
77 world.bloom.vx = Math.cos(a) * 100;
78 world.bloom.vy = Math.sin(a) * 100;
79 }
80
81 // ── The clock ──────────────────────────────────────────────────────────
82
83 test("a round is the tick count and nothing else: 3600 ticks is ROUND_SECONDS", () => {
84 const round = open({ seed: 7, startedAt: STARTED_AT });
85 expect(ROUND_TICKS).toBe(ROUND_SECONDS * TICK_HZ);
86 expect(remainingSeconds(round)).toBe(ROUND_SECONDS);
87
88 const before = run(round, ROUND_TICKS - 1);
89 expect(before.round.phase).toBe("running");
90 expect(kinds(before.effects, "ended")).toHaveLength(0);
91 expect(elapsedSeconds(before.round)).toBeCloseTo(ROUND_SECONDS - 1 / TICK_HZ, 9);
92 expect(remainingSeconds(before.round)).toBeCloseTo(1 / TICK_HZ, 9);
93
94 const last = tick(before.round);
95 const ended = kinds(last.effects, "ended");
96 expect(ended).toHaveLength(1);
97 const end = ended[0]!;
98 if (end.kind !== "ended") throw new Error("not an ended effect");
99 expect(end.outcome).toBe("clock");
100 expect(end.roundId).toBe(1);
101 expect(end.startedAt).toBe(STARTED_AT);
102 // The record's instants are the started-at plus a whole number of ticks,
103 // never a second reading of the wall clock.
104 expect(end.endedAt).toBe("2026-09-20T00:03:00.000Z");
105 expect(end.scores).toEqual([]);
106 expect(last.round.phase).toBe("intermission");
107 expect(last.round.tick).toBe(0);
108 });
109
110 test("a round announces itself once, on its first tick", () => {
111 const round = open({ seed: 3, startedAt: STARTED_AT });
112 const first = tick(round);
113 const started = kinds(first.effects, "started");
114 expect(started).toHaveLength(1);
115 expect(started[0]).toEqual({
116 kind: "started",
117 roundId: 1,
118 startedAt: STARTED_AT,
119 });
120 expect(kinds(run(first.round, 40).effects, "started")).toHaveLength(0);
121 });
122
123 test("the bloom through the gate ends the round sooner, with outcome gate", () => {
124 const round = open({ seed: 11, startedAt: STARTED_AT });
125 const early = run(round, 100);
126 expect(early.round.phase).toBe("running");
127 aimBloomAtGate(early.round.world);
128
129 const crossed = tick(early.round);
130 expect(withinGate(crossed.round.world.gate, Math.atan2(crossed.round.world.bloom.y, crossed.round.world.bloom.x))).toBe(true);
131 const ended = kinds(crossed.effects, "ended");
132 expect(ended).toHaveLength(1);
133 const end = ended[0]!;
134 if (end.kind !== "ended") throw new Error("not an ended effect");
135 expect(end.outcome).toBe("gate");
136 // 101 ticks in, not 3600: the clock was nowhere near out.
137 expect(end.endedAt).toBe("2026-09-20T00:00:05.050Z");
138 expect(crossed.round.phase).toBe("intermission");
139 });
140
141 // ── The quiet, and the next world ──────────────────────────────────────
142
143 test("the intermission is quiet, the sea keeps decaying, and the next round is new", () => {
144 let round = open({ seed: 21, startedAt: STARTED_AT });
145 round = takeSeat(round, "key-a", "a").round;
146 round = steer(round, "key-a", { x: 1, y: 0.3 });
147 round = run(round, 200).round;
148 aimBloomAtGate(round.world);
149 const gone = tick(round);
150 expect(gone.round.phase).toBe("intermission");
151 const stirred = fieldMagnitude(gone.round.world.field);
152 expect(stirred).toBeGreaterThan(0);
153 // The round's end frees every seat, but the bodies stay in the sea with
154 // their thrusters off: the quiet is what the round left, draining.
155 expect(gone.round.roster).toEqual([]);
156 const drifting = gone.round.world.motes[0]!;
157 expect(drifting.thrust).toEqual({ x: 0, y: 0 });
158
159 const quiet = run(gone.round, INTERMISSION_TICKS - 1);
160 expect(quiet.round.phase).toBe("intermission");
161 expect(kinds(quiet.effects, "started")).toHaveLength(0);
162 expect(kinds(quiet.effects, "ended")).toHaveLength(0);
163 expect(quiet.round.world.tick).toBe(201 + INTERMISSION_TICKS - 1);
164 expect(fieldMagnitude(quiet.round.world.field)).toBeLessThan(stirred / 4);
165 expect(remainingSeconds(quiet.round)).toBeCloseTo(1 / TICK_HZ, 9);
166
167 const rolled = tick(quiet.round);
168 expect(rolled.round.phase).toBe("running");
169 expect(rolled.round.id).toBe(2);
170 expect(rolled.round.tick).toBe(0);
171 expect(rolled.round.world.tick).toBe(0);
172 expect(rolled.round.world.motes).toEqual([]);
173 expect(fieldMagnitude(rolled.round.world.field)).toBe(0);
174 // A new gate, drawn from the world's own seeded RNG.
175 expect(rolled.round.world.gate.angle).not.toBe(gone.round.world.gate.angle);
176 // 201 ticks of round, then the whole quiet: 10.05 s + 20 s, by arithmetic.
177 expect(rolled.round.startedAt).toBe("2026-09-20T00:00:30.050Z");
178 expect(INTERMISSION_TICKS).toBe(INTERMISSION_SECONDS * TICK_HZ);
179 // And it announces itself on its own first tick, like the first one did.
180 expect(kinds(tick(rolled.round).effects, "started")).toHaveLength(1);
181 });
182
183 test("a seat taken in the quiet swims the next round, in a new body", () => {
184 const ended = run(open({ seed: 5, startedAt: STARTED_AT }), ROUND_TICKS);
185 const waiting = takeSeat(ended.round, "key-b", "b");
186 expect(waiting.seat).not.toBeNull();
187 const rolled = run(waiting.round, INTERMISSION_TICKS);
188 expect(rolled.round.phase).toBe("running");
189 expect(rolled.round.roster).toHaveLength(1);
190 const seat = rolled.round.roster[0]!;
191 expect(seat.playerKey).toBe("key-b");
192 expect(moteById(rolled.round.world, seat.moteId)?.name).toBe("b");
193 // The idle clock starts again with the new world, so nothing is released
194 // for having been quiet during a round that had not begun.
195 expect(seat.lastSteeredTick).toBe(0);
196 });
197
198 test("one seed replays to the same state, gate chain and all", () => {
199 const options = { seed: 99, startedAt: STARTED_AT } as const;
200 const a = run(open(options), ROUND_TICKS + INTERMISSION_TICKS + 5);
201 const b = run(open(options), ROUND_TICKS + INTERMISSION_TICKS + 5);
202 expect(a.round).toEqual(b.round);
203 expect(a.effects).toEqual(b.effects);
204 expect(a.round.id).toBe(2);
205 // And a different seed is a different world, or the replay proves nothing.
206 const other = run(open({ seed: 100, startedAt: STARTED_AT }), 5);
207 expect(other.round.world.gate.angle).not.toBe(a.round.world.gate.angle);
208 });
209
210 // ── Seats ──────────────────────────────────────────────────────────────
211
212 test("an idle seat goes at IDLE_SECONDS, an unsteered one 40 s earlier", () => {
213 expect(IDLE_SECONDS - UNSTEERED_SECONDS).toBe(40);
214 expect(IDLE_TICKS - UNSTEERED_TICKS).toBe(40 * TICK_HZ);
215
216 let round = open({ seed: 2, startedAt: STARTED_AT });
217 round = takeSeat(round, "idle", "idle").round;
218 round = takeSeat(round, "silent", "silent").round;
219 round = steer(round, "idle", { x: 0, y: 1 });
220
221 const early = run(round, UNSTEERED_TICKS - 1);
222 expect(early.round.roster.map((s) => s.playerKey)).toEqual(["idle", "silent"]);
223 const swept = tick(early.round);
224 expect(swept.round.roster.map((s) => s.playerKey)).toEqual(["idle"]);
225 // The mote goes with the seat.
226 expect(swept.round.world.motes.map((m) => m.name)).toEqual(["idle"]);
227
228 const held = run(swept.round, IDLE_TICKS - UNSTEERED_TICKS - 1);
229 expect(held.round.roster).toHaveLength(1);
230 const released = tick(held.round);
231 expect(released.round.roster).toEqual([]);
232 expect(released.round.world.motes).toEqual([]);
233 expect(released.round.world.tick).toBe(IDLE_TICKS);
234 });
235
236 test("steering keeps a seat, so a swimmer is never swept mid-round", () => {
237 let round = open({ seed: 4, startedAt: STARTED_AT });
238 round = takeSeat(round, "swimmer", "swimmer").round;
239 for (let i = 0; i < ROUND_TICKS - 1; i++) {
240 if (i % 100 === 0) round = steer(round, "swimmer", { x: 1, y: 0 });
241 round = tick(round).round;
242 }
243 expect(round.roster).toHaveLength(1);
244 expect(round.roster[0]!.steered).toBe(true);
245 });
246
247 test("one key is one seat, a full roster is a spectator, and a stranger steers nothing", () => {
248 let round = open({ seed: 6, startedAt: STARTED_AT });
249 const first = takeSeat(round, "key", "name");
250 round = first.round;
251 const again = takeSeat(round, "key", "another name");
252 expect(again.seat).toEqual(first.seat!);
253 expect(again.round.roster).toHaveLength(1);
254 expect(round.world.motes).toHaveLength(1);
255
256 for (let i = 1; i < ROSTER_MAX; i++) round = takeSeat(round, `key-${i}`, `m${i}`).round;
257 expect(round.roster).toHaveLength(ROSTER_MAX);
258 const over = takeSeat(round, "late", "late");
259 expect(over.seat).toBeNull();
260 expect(over.round).toBe(round);
261 expect(round.world.motes).toHaveLength(ROSTER_MAX);
262
263 // A key with no seat is ignored, not an error, and moves nothing.
264 const ignored = steer(round, "late", { x: 1, y: 0 });
265 expect(ignored).toBe(round);
266 expect(round.world.motes.every((m) => m.thrust.x === 0)).toBe(true);
267 // A seat given up takes its mote with it.
268 const left = releaseSeat(round, "key");
269 expect(left.roster).toHaveLength(ROSTER_MAX - 1);
270 expect(left.world.motes).toHaveLength(ROSTER_MAX - 1);
271 expect(releaseSeat(left, "key")).toBe(left);
272 });
273
274 test("a seat steers its own mote and nobody else's", () => {
275 let round = open({ seed: 8, startedAt: STARTED_AT });
276 round = takeSeat(round, "a", "a").round;
277 round = takeSeat(round, "b", "b").round;
278 const [a, b] = round.roster;
279 round = steer(round, "a", { x: 1, y: 0 });
280 expect(moteById(round.world, a!.moteId)!.thrust.x).toBeGreaterThan(0);
281 expect(moteById(round.world, b!.moteId)!.thrust).toEqual({ x: 0, y: 0 });
282 expect(round.roster[0]!.steered).toBe(true);
283 expect(round.roster[1]!.steered).toBe(false);
284 });
285
286 // ── Frames ─────────────────────────────────────────────────────────────
287
288 test("a frame every second tick, and never in between", () => {
289 let round = open({ seed: 13, startedAt: STARTED_AT });
290 round = takeSeat(round, "a", "a").round;
291 round = steer(round, "a", { x: 0.5, y: -0.5 });
292 const on: number[] = [];
293 for (let i = 1; i <= 21; i++) {
294 const out = tick(round);
295 round = out.round;
296 const frames = kinds(out.effects, "frame");
297 expect(frames.length).toBeLessThanOrEqual(1);
298 if (frames.length === 1) on.push(i);
299 }
300 expect(on).toEqual([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]);
301 expect(FRAME_EVERY).toBe(2);
302 expect(on.length / (21 / TICK_HZ)).toBeCloseTo(TICK_HZ / FRAME_EVERY, 0);
303 });
304
305 test("a frame is the protocol's shape: it encodes, decodes and says the clock", () => {
306 let round = open({ seed: 17, startedAt: STARTED_AT });
307 round = takeSeat(round, "a", "a").round;
308 round = steer(round, "a", { x: 1, y: 0 });
309 round = run(round, 39).round;
310
311 const out = tick(round);
312 const frames = kinds(out.effects, "frame");
313 expect(frames).toHaveLength(1);
314 const effect = frames[0]!;
315 if (effect.kind !== "frame") throw new Error("not a frame effect");
316 const frame: Frame = effect.frame;
317 expect(frame.roundId).toBe(1);
318 expect(frame.tick).toBe(out.round.world.tick);
319 expect(frame.clock).toBe(Math.round(remainingSeconds(out.round) * 1000));
320 expect(frame.motes.length).toBe(MOTE_STRIDE);
321
322 const decoded = decodeFrame(encodeFrame(frame));
323 expect(decoded.ok).toBe(true);
324 if (!decoded.ok) throw new Error(decoded.message);
325 const mote = out.round.world.motes[0]!;
326 expect(decoded.value.motes[0]).toBe(mote.id);
327 // Half a quantiser step is what the wire promises to keep (#PROTOCOL).
328 const tolerance = 1 / (2 * POSITION_SCALE);
329 expect(Math.abs(decoded.value.motes[1]! - mote.x)).toBeLessThanOrEqual(tolerance);
330 expect(Math.abs(decoded.value.motes[2]! - mote.y)).toBeLessThanOrEqual(tolerance);
331 expect(Math.abs(decoded.value.motes[3]! - mote.vx * DT)).toBeLessThanOrEqual(
332 1 / (2 * VELOCITY_SCALE),
333 );
334 expect(decoded.value.gate[0]).toBeCloseTo(out.round.world.gate.angle, 3);
335 expect(decoded.value.clock).toBe(frame.clock);
336 // The wire's field is the sea's, coarser: stirred here, so not all zero.
337 expect(decoded.value.field.some((v) => v !== 0)).toBe(true);
338 });
339
340 test("a frame carries at most the roster cap, whatever the sea holds", () => {
341 let round = open({ seed: 19, startedAt: STARTED_AT });
342 for (let i = 0; i < ROSTER_MAX; i++) round = takeSeat(round, `k${i}`, `m${i}`).round;
343 expect(round.world.motes).toHaveLength(ROSTER_MAX);
344 const frame = frameOf(round);
345 expect(frame.motes.length).toBe(ROSTER_MAX * MOTE_STRIDE);
346 expect(() => encodeFrame(frame)).not.toThrow();
347 });
348
349 test("a seat taken in the quiet is on the wire, and the drifters take what room is left", () => {
350 // A full round ends with its bodies still in the sea, so the quiet holds
351 // more motes than a frame can carry. Whoever is seated comes first.
352 let round = open({ seed: 23, startedAt: STARTED_AT });
353 for (let i = 0; i < ROSTER_MAX; i++) {
354 round = takeSeat(round, `key-${i}`, `m${i}`).round;
355 round = steer(round, `key-${i}`, { x: Math.cos(i), y: Math.sin(i) });
356 }
357 round = run(round, 100).round;
358 aimBloomAtGate(round.world);
359 round = tick(round).round;
360 expect(round.phase).toBe("intermission");
361 expect(round.roster).toEqual([]);
362 const drifting = round.world.motes.length;
363 expect(drifting).toBe(ROSTER_MAX);
364
365 const arrival = takeSeat(round, "newcomer", "newcomer");
366 round = arrival.round;
367 expect(round.world.motes).toHaveLength(ROSTER_MAX + 1);
368 const ids = (frame: Frame): number[] => {
369 const out: number[] = [];
370 for (let i = 0; i < frame.motes.length; i += MOTE_STRIDE) out.push(frame.motes[i]!);
371 return out;
372 };
373 const one = frameOf(round);
374 expect(one.motes.length).toBe(ROSTER_MAX * MOTE_STRIDE);
375 // The newcomer is on the wire, and the dead round's leftovers fill the rest.
376 expect(ids(one)).toContain(arrival.seat!.moteId);
377 expect(ids(one)).toHaveLength(ROSTER_MAX);
378
379 for (let i = 0; i < ROSTER_MAX - 1; i++)
380 round = takeSeat(round, `next-${i}`, `n${i}`).round;
381 expect(round.roster).toHaveLength(ROSTER_MAX);
382 const full = frameOf(round);
383 expect(ids(full).sort()).toEqual(round.roster.map((s) => s.moteId).sort());
384 expect(() => encodeFrame(full)).not.toThrow();
385 });
386
387 test("a frame in the quiet says the round's clock has run out", () => {
388 const ended = run(open({ seed: 27, startedAt: STARTED_AT }), ROUND_TICKS);
389 expect(ended.round.phase).toBe("intermission");
390 // The countdown to the next round is on the bus (#53), not in a number a
391 // mid-quiet joiner would read as a round with 20 s left.
392 expect(frameOf(ended.round).clock).toBe(0);
393 expect(remainingSeconds(ended.round)).toBe(INTERMISSION_SECONDS);
394
395 const running = run(ended.round, INTERMISSION_TICKS + 2).round;
396 expect(running.phase).toBe("running");
397 expect(frameOf(running).clock).toBe(Math.round((ROUND_SECONDS - 2 / TICK_HZ) * 1000));
398 });
399
400 test("a seat carried into the next round has to steer it to keep it", () => {
401 // Steering in the quiet is a nudge at a dying sea, not a round swum: the
402 // carried seat starts unsteered, so the record's shorter patience applies.
403 const ended = run(open({ seed: 29, startedAt: STARTED_AT }), ROUND_TICKS);
404 let round = takeSeat(ended.round, "waiting", "waiting").round;
405 round = steer(round, "waiting", { x: 1, y: 0 });
406 expect(round.roster[0]!.steered).toBe(true);
407
408 round = run(round, INTERMISSION_TICKS).round;
409 expect(round.phase).toBe("running");
410 expect(round.roster[0]!.steered).toBe(false);
411 const held = run(round, UNSTEERED_TICKS - 1);
412 expect(held.round.roster).toHaveLength(1);
413 expect(tick(held.round).round.roster).toEqual([]);
414 });
src/round.ts added +461
@@ -0,0 +1,461 @@
1 /**
2 * A round, as state and one `tick`. The clock, the end condition, the quiet
3 * between rounds, the next world and the seat bookkeeping the record decides
4 * (#WAKE, "A seat") — all of it arithmetic, none of it waiting.
5 *
6 * There is no `setInterval` here, no `Database`, no socket and no bus. A
7 * round is plain data; `tick(round)` returns the next round and a list of
8 * **effects** as data — `started`, `ended`, `frame`. What an effect *means*
9 * is the caller's: #53 owns the interval, writes the round row through
10 * `recordRound`, emits on the bus and publishes the frame to the sockets.
11 * That split is the whole point: every rule in this file is testable by
12 * calling a function 3600 times, and nothing in it needs a clock to be true.
13 *
14 * The clock is the tick count times `DT` and nothing else. A wall-clock
15 * delta would make a round longer on a busy box and shorter on an idle one,
16 * and would make a replay from a seed impossible; the one instant this
17 * module reads from the wall is the `startedAt` handed to `open`, and every
18 * instant after it is that one plus a whole number of ticks.
19 *
20 * The world is stepped in place — that is `world.ts`'s deliberate choice, so
21 * a tick allocates no grids — while the `Round` wrapper around it is a fresh
22 * small object each tick, so a caller can hold `round = tick(round).round`
23 * and never wonder which fields moved under it.
24 */
25
26 import {
27 IDLE_SECONDS,
28 INTERMISSION_SECONDS,
29 ROSTER_MAX,
30 ROUND_SECONDS,
31 UNSTEERED_SECONDS,
32 } from "./concept.ts";
33 import {
34 FIELD_LENGTH,
35 FIELD_SIZE,
36 MOTE_STUNNED,
37 type Frame,
38 } from "./protocol.ts";
39 import {
40 DT,
41 FIELD_CELLS as SEA_CELLS,
42 THRUST_MAX,
43 TICK_HZ,
44 addMote,
45 applyInput,
46 createWorld,
47 moteById,
48 nextRandom,
49 removeMote,
50 step,
51 wrapAngle,
52 type Steering,
53 type World,
54 } from "./world.ts";
55
56 // ── The numbers, as ticks ──────────────────────────────────────────────
57 // The record owns the seconds (`src/concept.ts`); this is the same numbers
58 // in the only unit the loop counts in, so nothing below ever divides.
59
60 /** How long a round runs, in ticks: 180 s at 20 Hz. */
61 export const ROUND_TICKS = Math.round(ROUND_SECONDS * TICK_HZ);
62 /** The quiet after it, in ticks. */
63 export const INTERMISSION_TICKS = Math.round(INTERMISSION_SECONDS * TICK_HZ);
64 /** A seat whose key has stopped steering is released after this many ticks. */
65 export const IDLE_TICKS = Math.round(IDLE_SECONDS * TICK_HZ);
66 /** A seat that has never been steered goes sooner. */
67 export const UNSTEERED_TICKS = Math.round(UNSTEERED_SECONDS * TICK_HZ);
68 /** A frame every second tick: 20 Hz of simulation, 10 Hz on the wire. */
69 export const FRAME_EVERY = 2;
70
71 // ── The state ──────────────────────────────────────────────────────────
72
73 /** Running a round, or in the quiet between two. */
74 export type Phase = "running" | "intermission";
75
76 /** Why a round ended: the clock ran out, or the bloom went through the gate. */
77 export type Outcome = "clock" | "gate";
78
79 /**
80 * One seat: a player key, the mote it steers, and the two facts the record
81 * releases a seat on. `lastSteeredTick` is a tick of *this round's* world —
82 * a seat carried into a new world starts its idle clock again — and it holds
83 * the tick the seat was taken until the key first steers.
84 */
85 export interface Seat {
86 readonly playerKey: string;
87 readonly moteId: number;
88 readonly lastSteeredTick: number;
89 readonly steered: boolean;
90 }
91
92 /** What a finished round pays, in `recordRound`'s shape. Empty until #12. */
93 export interface Score {
94 readonly player_id: string;
95 readonly points: number;
96 }
97
98 export interface Round {
99 /** The round's number in this process, and the `roundId` on the wire. */
100 readonly id: number;
101 readonly world: World;
102 readonly phase: Phase;
103 /** Ticks spent in this phase. The clock is this times `DT`. */
104 readonly tick: number;
105 /**
106 * When this phase began, as an ISO instant. For a running round it is the
107 * round's started-at, the one the record keeps; during the intermission it
108 * is the instant the round ended.
109 */
110 readonly startedAt: string;
111 readonly roster: readonly Seat[];
112 }
113
114 /**
115 * Something that happened on a tick, as data. The caller decides what it
116 * means — an emit, a row, bytes on a socket — and this module decides when
117 * one happens.
118 */
119 export type Effect =
120 | { readonly kind: "started"; readonly roundId: number; readonly startedAt: string }
121 | {
122 readonly kind: "ended";
123 readonly roundId: number;
124 readonly outcome: Outcome;
125 readonly startedAt: string;
126 readonly endedAt: string;
127 readonly scores: readonly Score[];
128 }
129 | { readonly kind: "frame"; readonly frame: Frame };
130
131 export interface Ticked {
132 readonly round: Round;
133 readonly effects: readonly Effect[];
134 }
135
136 export interface RoundOptions {
137 /** The world's seed; every choice a round makes descends from it. */
138 seed?: number;
139 /** Where the gate sits; drawn from the seed when absent. */
140 gateAngle?: number;
141 /** The round's number. The next round is this plus one. */
142 id?: number;
143 /** When it started, as an ISO instant. Now, when absent. */
144 startedAt?: string;
145 }
146
147 // ── Opening one ────────────────────────────────────────────────────────
148
149 /**
150 * A round about to run: a fresh world, nobody seated, the clock at zero.
151 * Nothing has happened yet — the `started` effect belongs to the first
152 * `tick`, so the first round and every round after it announce themselves
153 * the same way.
154 */
155 export function open(options: RoundOptions = {}): Round {
156 const world = createWorld({
157 ...(Number.isFinite(options.seed) ? { seed: options.seed as number } : {}),
158 ...(Number.isFinite(options.gateAngle)
159 ? { gateAngle: options.gateAngle as number }
160 : {}),
161 });
162 return {
163 id: wholeId(options.id),
164 world,
165 phase: "running",
166 tick: 0,
167 startedAt: instant(options.startedAt),
168 roster: [],
169 };
170 }
171
172 // ── One tick ───────────────────────────────────────────────────────────
173
174 /**
175 * Advance a round by one tick: step the sea, release the seats the record
176 * says are gone, end the round if the clock or the gate says so, roll into
177 * the next one when the quiet is up, and hand out a frame every second tick.
178 *
179 * Never throws and never reads a clock, so a round is a pure function of its
180 * seed and what was steered: two rounds opened the same way and driven the
181 * same way stay identical to the last cell. It is not a rewind, though — the
182 * world under a `Round` is stepped in place (the module header says why), so
183 * a `Round` handed to `tick` twice is a world stepped twice.
184 */
185 export function tick(round: Round): Ticked {
186 const effects: Effect[] = [];
187 const count = round.tick + 1;
188 if (round.phase === "running" && count === 1)
189 effects.push({
190 kind: "started",
191 roundId: round.id,
192 startedAt: round.startedAt,
193 });
194
195 step(round.world);
196 let next: Round = {
197 ...round,
198 tick: count,
199 // Seats are swept while a round is running and not during the quiet: the
200 // round's end frees everyone anyway, so the only seats the intermission
201 // holds are the ones taken for the round that has not begun, and the
202 // record's patience is measured in the round a seat was taken from.
203 roster:
204 round.phase === "running" ? prune(round.world, round.roster) : round.roster,
205 };
206
207 if (round.phase === "running") {
208 // The gate is the world's word: `step` records the tick the bloom passed
209 // `withinGate`, so the round reads a crossing rather than re-deciding it.
210 const outcome: Outcome | null =
211 next.world.crossedAt !== null
212 ? "gate"
213 : count >= ROUND_TICKS
214 ? "clock"
215 : null;
216 if (outcome !== null) {
217 const endedAt = after(round.startedAt, count);
218 effects.push({
219 kind: "ended",
220 roundId: round.id,
221 outcome,
222 startedAt: round.startedAt,
223 endedAt,
224 scores: [],
225 });
226 // A seat opens when the round ends (#WAKE, "A seat"): every key is
227 // released here and #8 seats the next round. The bodies stay in the
228 // sea with their thrusters off — nobody steers them, and the quiet is
229 // the sea emptying with what the round left drifting in it.
230 for (const seat of next.roster) {
231 const mote = moteById(next.world, seat.moteId);
232 if (mote !== undefined) {
233 mote.thrust.x = 0;
234 mote.thrust.y = 0;
235 }
236 }
237 next = { ...next, phase: "intermission", tick: 0, startedAt: endedAt, roster: [] };
238 }
239 } else if (count >= INTERMISSION_TICKS) {
240 next = roll(next);
241 }
242
243 // The frame is built from where the tick left the round, so the one sent on
244 // a rolling tick is the new world and the new clock rather than the old.
245 if (count % FRAME_EVERY === 0)
246 effects.push({ kind: "frame", frame: frameOf(next) });
247 return { round: next, effects };
248 }
249
250 /** The next round: a new gate from the world's own seeded RNG, seats carried. */
251 function roll(round: Round): Round {
252 const old = round.world;
253 const opened = open({
254 id: round.id + 1,
255 seed: old.seed,
256 gateAngle: wrapAngle(nextRandom(old) * Math.PI * 2),
257 startedAt: after(round.startedAt, INTERMISSION_TICKS),
258 });
259 // Seats taken during the quiet swim the round they were taken for: a new
260 // mote in the new world, at a place the new seed chose, and an idle clock
261 // that starts again with it. A nudge at the dying sea is not a round
262 // steered, so a carried seat is unsteered again and has the record's
263 // shorter patience to prove otherwise.
264 const roster = round.roster.slice(0, ROSTER_MAX).map((seat) => ({
265 ...seat,
266 moteId: addMote(opened.world, moteById(old, seat.moteId)?.name ?? "mote").id,
267 lastSteeredTick: opened.world.tick,
268 steered: false,
269 }));
270 return { ...opened, roster };
271 }
272
273 /** Release the seats the record has run out of patience with, motes and all. */
274 function prune(world: World, roster: readonly Seat[]): readonly Seat[] {
275 const kept = roster.filter((seat) => {
276 const limit = seat.steered ? IDLE_TICKS : UNSTEERED_TICKS;
277 if (world.tick - seat.lastSteeredTick < limit) return true;
278 // An idle seat's mote leaves the sea; its wake stays, which is the point.
279 removeMote(world, seat.moteId);
280 return false;
281 });
282 return kept.length === roster.length ? roster : kept;
283 }
284
285 // ── Seats ──────────────────────────────────────────────────────────────
286
287 /**
288 * Put a key in a seat. This is the bookkeeping, not the admission: who may
289 * have a seat, and what a spectator is told, is `join`'s (#8). A key that
290 * already holds a seat gets that same seat back — one key, one seat
291 * (invariant 7) — and a full roster answers `null`, which is the caller's
292 * cue to hand back the spectator's sentence.
293 */
294 export function takeSeat(
295 round: Round,
296 playerKey: string,
297 name: string,
298 ): { round: Round; seat: Seat | null } {
299 if (typeof playerKey !== "string" || playerKey === "")
300 return { round, seat: null };
301 const held = round.roster.find((s) => s.playerKey === playerKey);
302 if (held !== undefined) return { round, seat: held };
303 if (round.roster.length >= ROSTER_MAX) return { round, seat: null };
304 const seat: Seat = {
305 playerKey,
306 moteId: addMote(round.world, name).id,
307 lastSteeredTick: round.world.tick,
308 steered: false,
309 };
310 return { round: { ...round, roster: [...round.roster, seat] }, seat };
311 }
312
313 /** Give a seat up: the mote leaves the sea, the wake it left does not. */
314 export function releaseSeat(round: Round, playerKey: string): Round {
315 const seat = round.roster.find((s) => s.playerKey === playerKey);
316 if (seat === undefined) return round;
317 removeMote(round.world, seat.moteId);
318 return {
319 ...round,
320 roster: round.roster.filter((s) => s.playerKey !== playerKey),
321 };
322 }
323
324 /**
325 * Steer a key's own mote and note that the key is still here. A key with no
326 * seat is ignored rather than refused: input arrives from a socket, and a
327 * socket that outlives its seat is ordinary. The mote id is never read off
328 * the wire — the seat is the only mapping from a person to a body.
329 */
330 export function steer(
331 round: Round,
332 playerKey: string,
333 input: Steering,
334 ): Round {
335 const seat = round.roster.find((s) => s.playerKey === playerKey);
336 if (seat === undefined) return round;
337 if (!applyInput(round.world, seat.moteId, input)) return round;
338 const steered: Seat = {
339 ...seat,
340 lastSteeredTick: round.world.tick,
341 steered: true,
342 };
343 return {
344 ...round,
345 roster: round.roster.map((s) => (s === seat ? steered : s)),
346 };
347 }
348
349 // ── What the clock reads ───────────────────────────────────────────────
350
351 /** Ticks left in this phase, never below zero. */
352 export const remainingTicks = (round: Round): number =>
353 Math.max(0, (round.phase === "running" ? ROUND_TICKS : INTERMISSION_TICKS) - round.tick);
354
355 /** Seconds left in this phase: the tick count and `DT`, never a wall clock. */
356 export const remainingSeconds = (round: Round): number =>
357 remainingTicks(round) * DT;
358
359 /** Seconds spent in this phase. */
360 export const elapsedSeconds = (round: Round): number => round.tick * DT;
361
362 // ── The frame ──────────────────────────────────────────────────────────
363
364 /**
365 * What one frame carries, in the shape `src/protocol.ts` defines. Velocities
366 * and currents travel in units per *tick* — that is the unit the wire's
367 * scales are cut for, and it keeps a fast lane inside the byte it is given —
368 * and a thruster travels as the direction it was steered in, a fraction of
369 * `THRUST_MAX` in the unit disc. The field is the sea's 32×32 grid averaged
370 * down to 16×16, two components a cell, interleaved `x, y`, row-major.
371 *
outdated

bun run test is green on this head for me too (127 pass, tsc --noEmit clean), the module imports no timer, no Database, no socket and no bus, and every rule really is reachable by calling a function — that is what #58 asked for and it is done well. Security is clean: there is no identity in here to get wrong, the seat is the only map from a person to a body (steer takes a player key, never a mote id off the wire, which is invariant 7 held at the one place that could break it), names go through cleanName, steering through applyInput's finite-and-clamped check, and there is no shell, no SQL, no secret, no eval, no new dependency.

One blocker, and it is on the line this is anchored to.

High — during the quiet the frame shows the dead round's bodies and hides everybody waiting

The round's end empties the roster but leaves the motes drifting (deliberate, and I like it), while takeSeat during the intermission adds a live mote to that same sea. After a busy round the sea therefore holds up to 48 bodies, and this world.motes.slice(0, ROSTER_MAX) keeps the first 24 — which are exactly the leftovers, because addMote pushes. Measured on 07b3255:

quiet begins:              roster 0, motes left drifting 24
one newcomer takes a seat: world motes 25, frame motes 24, newcomer visible? false
24 newcomers take seats:   world motes 48, frame motes 24   (every newcomer is off the wire)

So for the whole 20 s intermission — one sixth of the cycle, and precisely the moment the record promises "a seat opens when the round ends" — the page draws 24 ghosts and none of the people who are seated for the next round. Nothing throws, which is why the tests miss it.

Fix this first: choose the motes rather than truncating them. Seated bodies first, then let what the round left drifting have the spare room:

const seated = new Set(round.roster.map((s) => s.moteId));
const carried = [
  ...world.motes.filter((m) => seated.has(m.id)),
  ...world.motes.filter((m) => !seated.has(m.id)),
].slice(0, ROSTER_MAX);

with a test that ends a round with a full, steering roster, takes a seat in the quiet and asserts that seat's mote id is in frameOf's motes — it fails today.

Medium — a frame in the quiet is indistinguishable from a round with 20 s left

Same function. During the intermission frameOf reports the ended round's roundId and the intermission's countdown in clock, so a socket that connects mid-quiet reads "round 1, 12.4 s left" and has no way to know better; started/ended only help a client that was already listening. Protocol v1 has no phase bit, so the honest thing this module can do without touching the wire is report clock: 0 while phase === "intermission" — the round's clock has genuinely run out — and leave the rest to #53. If you would rather the phase travelled on the wire, say so and I will open that against src/protocol.ts instead of holding it here.

Low, take or leave

  • roll carries steered into the new world while resetting lastSteeredTick. A client that steered for one tick of the quiet buys 60 s of patience in a round it has never steered; steered: false on a carried seat is what the record is actually counting.
  • The tick doc says "the same round in gives the same round and the same effects out", but the world is stepped in place, so calling tick twice on one Round does not. The module header already explains the in-place world — worth softening that sentence so nobody builds a rewind on it.
  • round.test.ts imports from ./protocol.ts twice (the big import, then encodeFrame alone); fold them.
  • FIELD_CELLS is 32 in world.ts and 256 in protocol.ts, and downsample wants the world's. It is correct as written; a rename-on-import would save the next reader the check.

Not held against it: the preview is unchanged because #58 explicitly kept the interval, the sockets and the bus in #53 — that is the split working, not a gap. I also checked the units frameOf chose land inside the wire's byte: four motes braiding one lane peak at 3.33 u/tick against a FIELD_MAX of 8, so per-tick was the right call and there is headroom for a crowd.

372 * A frame carries at most `ROSTER_MAX` motes, and during the quiet the sea
373 * can hold more than that: the ended round's bodies are still drifting while
374 * seats are taken for the next one. So the motes are *chosen*, not
375 * truncated — everyone seated first, and what the round left drifting takes
376 * the room that is spare. Truncating would have shown the leftovers and hidden
377 * every newcomer for the whole intermission, which is exactly the moment the
378 * record promises a seat opens.
379 */
380 export function frameOf(round: Round): Frame {
381 const world = round.world;
382 const seated = new Set(round.roster.map((s) => s.moteId));
383 const shown = [
384 ...world.motes.filter((m) => seated.has(m.id)),
385 ...world.motes.filter((m) => !seated.has(m.id)),
386 ].slice(0, ROSTER_MAX);
387 const motes: number[] = [];
388 for (const m of shown)
389 motes.push(
390 m.id,
391 m.x,
392 m.y,
393 m.vx * DT,
394 m.vy * DT,
395 m.thrust.x / THRUST_MAX,
396 m.thrust.y / THRUST_MAX,
397 world.tick < m.stunnedUntil ? MOTE_STUNNED : 0,
398 );
399 return {
400 tick: world.tick,
401 // The clock is the round's, and in the quiet the round's clock has run
402 // out: zero rather than the intermission's countdown, so a socket that
403 // connects mid-quiet cannot read it as a round with 20 s left. Version 1
404 // of the wire has no phase bit; how long the quiet has left is what
405 // `round.ended` on the bus is for (#53), not a number that lies.
406 clock: round.phase === "running" ? Math.round(remainingSeconds(round) * 1000) : 0,
407 roundId: round.id,
408 motes,
409 bloom: [world.bloom.x, world.bloom.y, world.bloom.vx * DT, world.bloom.vy * DT],
410 gate: [world.gate.angle, world.gate.half],
411 field: downsample(round),
412 };
413 }
414
415 // The sea's grid is 32 cells a side and the wire's is 16: `FIELD_CELLS`
416 // means both, one in each module, so the sea's is named for the sea here.
417 const BLOCK = SEA_CELLS / FIELD_SIZE;
418
419 /** The sea's grid, averaged into the wire's coarser one, in units per tick. */
420 function downsample(round: Round): number[] {
421 const { x, y } = round.world.field;
422 const out = new Array<number>(FIELD_LENGTH).fill(0);
423 const share = DT / (BLOCK * BLOCK);
424 for (let cy = 0; cy < FIELD_SIZE; cy++)
425 for (let cx = 0; cx < FIELD_SIZE; cx++) {
426 let sx = 0;
427 let sy = 0;
428 for (let dy = 0; dy < BLOCK; dy++)
429 for (let dx = 0; dx < BLOCK; dx++) {
430 const i = (cy * BLOCK + dy) * SEA_CELLS + (cx * BLOCK + dx);
431 sx += x[i]!;
432 sy += y[i]!;
433 }
434 const at = (cy * FIELD_SIZE + cx) * 2;
435 out[at] = sx * share;
436 out[at + 1] = sy * share;
437 }
438 return out;
439 }
440
441 // ── Small arithmetic ───────────────────────────────────────────────────
442
443 const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
444
445 /** An ISO instant the caller can trust: theirs when it is one, now when not. */
446 function instant(value: string | undefined): string {
447 return typeof value === "string" && ISO.test(value)
448 ? value
449 : new Date().toISOString();
450 }
451
452 /** That instant, a whole number of ticks later. */
453 function after(base: string, ticks: number): string {
454 const at = Date.parse(base);
455 return new Date(
456 (Number.isFinite(at) ? at : 0) + Math.round(ticks * DT * 1000),
457 ).toISOString();
458 }
459
460 const wholeId = (value: number | undefined): number =>
461 typeof value === "number" && Number.isInteger(value) && value > 0 ? value : 1;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs