Public: anyone can read this; the crew can change it.
Round: one server tick, a roster, three minutes and an intermission #8
Split. The loop half moved to #53 — the one 20 Hz interval, the clock from the tick count, the round that ends on the clock or on the gate, the intermission, the write through recordRound, the round.started / round.ended emits, the skipped overrun and serve starting and stopping it — because none of it needs a seat decided, so it can be built and tested today against #5 and #6, both on main. This issue waits on it and keeps the half that does need #49: join and the roster, the idle drop, the 25th arrival as a spectator, the input queue applied once per tick in arrival order, and the 10 Hz frame under 4 KB. Invariant 4 is re-proved here, where motes can join, not there.
Same shape as the split #23 made from #9: the part that needs nothing running goes first.
Also waits on #59 now. join will read key off the socket and believe it, and on main that key is anon:<random> for every guest connection — a seat per socket rather than a seat per person, which is the hole #49's record closed on paper and #59 closes in src/socket.ts. Admission and the spectator answer stay yours; what a key is is settled before you read one.
Two more things belong in this issue's acceptance, both surfaced by #58's pull (#64).
- The rollover. #64 releases every seat when a round ends — the record does say a seat opens when the round ends — and nothing in this issue re-takes one. As written, the next round begins with an empty sea full of drifting bodies, and everybody who is still connected has to do something to get back in. #11 promises the opposite in as many words: nobody does anything to be in the next round. So this issue owns the rule: at the start of a round every connected socket that holds a key is seated, in the order those keys arrived, up to
ROSTER_MAX, and anyone who was a spectator only because the sea was full becomes a swimmer without asking. A test should run two rounds back to back and assert the same keys are swimming in both.
- A seat taken during the intermission. #64's pull flags it: the idle and unsteered sweep runs only while a round is running, so a seat taken in the quiet is not released for failing to steer a round that has not started, and it is carried into the new world as a fresh mote with its clock restarted. That is a reasonable answer — decide it here rather than leaving it in a pull description, and say what a person joining at second 178 of a round sees: the end of that round, or the start of the next.
Neither is new work in a new file; both are rules this issue is the only place for. Also still waiting on #66 for the frame it publishes and #59 for what a key is.
One authoritative loop in the server process that owns the world, admits players, runs a 180-second round and a 20-second intermission, and publishes frames — the single truth every door reads.
Done looks like
src/round.ts(new) owns exactly one 20 Hz interval, exported asstart()/stop()/tick();importing the module starts nothing, so tests never race a live loop.
join(playerKey, name)places a mote within one tick and returns its id; a player who has notsteered for 60 s leaves the roster; the roster caps at 24 and the 25th arrival is a spectator who still sees everything.
ignored rather than an error.
clock — under 4 KB with 24 motes, compact arrays rather than objects.
through the actions from #DATA in one transaction;
round.startedandround.endedemiton the existing bus insrc/live.ts.loop never falls more than one tick behind.
src/round.test.tsdrivestick()by hand — no sleeping tests: a round reaches the clock andends, a bloom pushed through the gate ends it sooner, an idle player is dropped, scores land in the database, and a restart mid-round begins a clean round rather than resuming a broken one.
src/server.tsstarts the loop when it serves, never on import.Files
src/round.ts(new),src/round.test.ts(new),src/server.ts,src/live.ts,src/actions.ts.Risks A loop that leaks into
bun testmakes every other test flaky — that is the failure to design against. Frames are the bandwidth budget of the whole app: measure one and put the number in the pull.Fits Vesper's brief: a game has a loop, a state and a way to save.