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

stopped no changes pushed: You've hit your weekly limit · resets 2am (UTC) (api_error) opened this
opened this

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 on main today. 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 as start() / stop() / tick() / current(). Importing the module starts nothing, so bun test never races a live loop.
  • [ ] The loop holds one World from createWorld and calls step once per tick. The clock comes from the tick count, never from a wall-clock delta.
  • [ ] A round runs ROUND_SECONDS and ends on the clock or on the bloom crossing the gate (withinGate); then INTERMISSION_SECONDS of quiet; then a fresh world with a new gate. current() answers what state it is in and how long is left, as plain data.
  • [ ] Round end writes through recordRound(db, SYSTEM, …) in one transaction with a short outcome word (gate or clock) and an empty score list until #12 pays anyone. A failed write is logged and the loop keeps ticking — recordRound returns failures precisely so a 20 Hz caller never throws.
  • [ ] round.started and round.ended emit on the existing bus in src/live.ts, so the page's data-live regions have something to hear for the first time.
  • [ ] An overrunning tick is skipped rather than queued: the loop never falls more than one tick behind, and a skipped tick is counted and readable through current() rather than silent. #39's budget is the number that says whether this ever happens.
  • [ ] src/server.ts starts the loop when it serves and stops it when the server closes; nothing starts on import, and a test that calls serve can stop everything it started.
  • [ ] src/round.test.ts drives tick() 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 in src/round.test.ts only 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. recordRound refuses every caller but SYSTEM; 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.

commented

You've hit your weekly limit · resets 2am (UTC)

commented

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)

commented

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 one Round from #58. Importing starts nothing; start returns its own stop, so no test can leak a timer.
  • [ ] Each tick calls tick(round) once and spends its effects: a started or ended effect emits round.started / round.ended on the existing bus in src/live.ts, an ended effect also writes through recordRound(db, SYSTEM, …) in one transaction, and a frame effect goes to the sockets through publish in src/socket.ts.
  • [ ] Steering arrives through onSteer (already on main from #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. recordRound returns 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.ts starts the loop when it serves and stops it when the server closes; nothing starts on import, and src/socket.ts's sweep is called on the same interval rather than a second one.
  • [ ] Tests: the interval is created and cleared exactly once per start/stop; an ended effect lands a round row with its outcome; a frame effect reaches a fake socket; a steer under an unknown key is dropped; a failing recordRound is 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs