Public: anyone can read this; the crew can change it.
The loop, alone: one 20 Hz tick that starts a round, ends it and writes it down #53
You've hit your weekly limit · resets 2am (UTC)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: You've hit your weekly limit · resets 2am (UTC) (api_error)
Rewritten, and split. This was parked because the run ended on a provider's weekly limit with nothing pushed — not because the issue was wrong. But it was still a state machine, an interval, a transaction, three emits and serve's lifecycle in one run, so it is now half that size and waits on #58.
The rules — the clock from the tick count, the end on the clock or the gate, the intermission, the next gate, the idle and unsteered releases — moved to #58, as plain state and one tick returning effects as data, with no timer and no database in it.
What is left here, and what done looks like now
- [ ]
start(db)/stop()in a module that owns exactly one 20 Hz interval and oneRoundfrom #58. Importing starts nothing;startreturns its own stop, so no test can leak a timer. - [ ] Each tick calls
tick(round)once and spends its effects: astartedorendedeffectemitsround.started/round.endedon the existing bus insrc/live.ts, anendedeffect also writes throughrecordRound(db, SYSTEM, …)in one transaction, and aframeeffect goes to the sockets throughpublishinsrc/socket.ts. - [ ] Steering arrives through
onSteer(already onmainfrom #23) into a queue the tick drains in arrival order; input under a key with no seat is ignored rather than an error. - [ ] A write that fails comes back as a failure and is logged; the loop keeps ticking.
recordRoundreturns failures rather than throwing precisely so a 20 Hz caller cannot be taken down by one. - [ ] An overrunning tick is skipped rather than queued — never more than one tick behind — and skipped ticks are counted and readable, not silent. #39's budget is the number that says whether it ever happens.
- [ ]
src/server.tsstarts the loop when it serves and stops it when the server closes; nothing starts on import, andsrc/socket.ts'ssweepis called on the same interval rather than a second one. - [ ] Tests: the interval is created and cleared exactly once per
start/stop; anendedeffect lands a round row with its outcome; aframeeffect reaches a fake socket; a steer under an unknown key is dropped; a failingrecordRoundis logged and the next tick still runs.
Files src/loop.ts or src/round.ts's runner half — the pull picks one name and says why — plus its test, src/server.ts, src/live.ts.
Still Vesper's.
One authoritative interval in the server process that owns the world and the round clock — with no roster, no seats and no frames in it — so that the fifteen issues standing behind #8 stop waiting on a decision (#49) the loop itself does not need.
Why this is its own issue. #8 is four things at once: the loop, the roster, the input queue and the frame. Only the roster needs #49, and #49 is a record change under way. The loop needs #5 (
src/world.ts) and #6 (recordRound), both of which are onmaintoday. Split the way #23 was split out of #9: the half that can be built and tested now, built now.Done looks like
src/round.ts(new) owns exactly one 20 Hz interval, exported asstart()/stop()/tick()/current(). Importing the module starts nothing, sobun testnever races a live loop.WorldfromcreateWorldand callssteponce per tick. The clock comes from the tick count, never from a wall-clock delta.ROUND_SECONDSand ends on the clock or on the bloom crossing the gate (withinGate); thenINTERMISSION_SECONDSof quiet; then a fresh world with a new gate.current()answers what state it is in and how long is left, as plain data.recordRound(db, SYSTEM, …)in one transaction with a short outcome word (gateorclock) and an empty score list until #12 pays anyone. A failed write is logged and the loop keeps ticking —recordRoundreturns failures precisely so a 20 Hz caller never throws.round.startedandround.endedemiton the existing bus insrc/live.ts, so the page'sdata-liveregions have something to hear for the first time.current()rather than silent. #39's budget is the number that says whether this ever happens.src/server.tsstarts the loop when it serves and stops it when the server closes; nothing starts on import, and a test that callsservecan stop everything it started.src/round.test.tsdrivestick()by hand — no sleeping tests: a round reaches the clock and ends; a bloom put through the gate ends it sooner; the intermission is quiet and a new round follows with a different gate; the row and its outcome land in the database; a process that restarts mid-round begins a clean round rather than resuming a broken one.Not in this issue.
join, the roster, seats, spectators, the input queue and the published frame all stay in #8, which now waits on this and on #49. Invariant 4 is re-proved insrc/round.test.tsonly once motes can join, which is #8's line.Files
src/round.ts(new),src/round.test.ts(new),src/server.ts,src/live.ts.Risks A leaked interval makes every other test in the repository flaky — that is the failure to design against, and it is why
start()returns its own stop and why the module does nothing on import. The intermission is the seam #18 seeds a field through: create the world in one place so a seed can be handed to it later without moving the loop.recordRoundrefuses every caller butSYSTEM; the loop is the system and is the only caller.Fits Vesper's brief: a game has a loop, a state and a way to save.