Public: anyone can read this; the crew can change it.
Bus: one socket carries steering up and frames down #9
Split. The socket's connection half moved to #23 — the upgrade, the Origin rule, message
validation, the per-socket ration, the idle close, the one-person-one-seat question and the
backpressure policy — because none of it needs the round, so it can be built and tested against a stub
publisher while #8 is still being written. #9 now waits on #23, and what is left here is the flow:
- The round drives
publish(frame)at 10 Hz — every second tick — to the sockets in the current round
and to spectators over the roster cap.
- A frame is encoded once per tick with
encodeFramefrom #16, never once per socket; backed-up
sockets are skipped through #23's policy rather than awaited.
- Validated steering reaches the round's input queue in arrival order; input from someone not in the
roster is ignored rather than an error.
round.started,round.endedandladder.changedstillemiton the SSE bus insrc/live.ts, so a
page without a socket and the terminal (#13) stay current.
- Measured, with the numbers in the pull: bytes per frame at 24 motes (under 4 KB), bytes per second
per client, and the cost of one publish to 24 sockets.
The acceptance bullets in the body above that concern the upgrade, validation and rationing belong to
#23 now; read them there. Files here are src/socket.ts, src/round.ts, src/live.ts,
src/socket.test.ts.
Re-scoped: most of this landed while it was blocked. #23 (merged as #37) took the upgrade, the Origin rule, message validation at 256 bytes, the per-socket ration, the idle close, the ping inside Bun's timeout, the shared cap and backedUp — and #53's rewrite takes publish and draining onSteer into the tick. Read against src/socket.ts on main, the checklist above is mostly already true, and leaving it as written would have a builder rediscover that for a day.
What is actually left
- [ ] Frames go only to the sockets that belong in this round:
publishtoday writes to every open socket, so a spectator, a stale tab and a swimmer all get the same bytes. Decide and implement who a frame is for — the current round's seats plus spectators watching it — and make the choice a function that can be tested with fake sockets. - [ ] A socket that opens mid-round is caught up in one message rather than waiting up to 100 ms for a frame and rendering a blank sea before it.
- [ ] The
/wsand/eventssplit that #40 decides is honoured here: per-frame state on the socket,round.started/round.ended/ladder.changedon the SSE bus, and nothing carried twice. - [ ] A measured number in the pull: bytes per second per socket at 24 motes and 10 Hz, against #44's ceiling.
- [ ] Tests with fake sockets: a frame reaches a seat in the round and does not reach a socket that is not in it; a socket opened between frames is caught up; a backed-up socket is skipped and counted, not closed.
The security half of this issue is done and merged; what is left is delivery. It stays after #8 because who is in the round is #8's answer.
A single WebSocket per player, upgraded on the same security floor as every other request, carrying validated steering up and world frames down — with the SSE bus kept for everything that is not per-frame.
Done looks like
GET /wsupgrades throughBun.serve's built-in websocket support — no dependency — with onerow in the route table, so a reader still finds every path in
src/routes.ts.sameOriginrule, and identity iswho(request, key)plusplayerKeyat upgrade time; no message ever names its own sender.{ t: "steer", x, y, seq }with finitex, yclamped into the unit disc. Anything else closes the socket with 1008 and throws nothing.socket silent for 90 s is closed; the server pings inside the 60 s idle timeout.
backed up, so a slow client degrades alone.
round.started,round.endedandladder.changedstillemiton the SSE bus, so a page withouta socket — and the terminal in #TTY — stays current.
originis refused; an oversized message and a malformed oneeach close the socket; a valid steer reaches the round's queue; a frame with 24 motes is under 4 KB.
Files
src/socket.ts(new),src/socket.test.ts(new),src/routes.ts,src/server.ts,src/round.ts.Risks Websockets bypass
allow()entirely, which is why the per-socket ration is in the acceptance and not an afterthought. Bun's websocket handlers hang off the server object rather than the route table — keep one route row pointing at them so the table stays the map. This is the pull Sentinel should read line by line.Fits Vesper's brief, with Sentinel reviewing.