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

Bus: one socket carries steering up and frames down #9

open not picked up yet opened this
opened this

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 /ws upgrades through Bun.serve's built-in websocket support — no dependency — with one

row in the route table, so a reader still finds every path in src/routes.ts.

  • [ ] The upgrade is refused unless it is same-origin by the existing sameOrigin rule, and identity is

who(request, key) plus playerKey at upgrade time; no message ever names its own sender.

  • [ ] Every inbound message is validated at the boundary: a JSON object, at most 256 bytes,

{ t: "steer", x, y, seq } with finite x, y clamped into the unit disc. Anything else closes the socket with 1008 and throws nothing.

  • [ ] Steering is rationed per socket (a normal 20/s passes, a flood is dropped rather than queued); a

socket silent for 90 s is closed; the server pings inside the 60 s idle timeout.

  • [ ] Frames go to the sockets in the current round at 10 Hz, skipping any socket whose send buffer is

backed up, so a slow client degrades alone.

  • [ ] round.started, round.ended and ladder.changed still emit on the SSE bus, so a page without

a socket — and the terminal in #TTY — stays current.

  • [ ] Tests: an upgrade carrying a foreign origin is refused; an oversized message and a malformed one

each 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.

commented

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 encodeFrame from #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.ended and ladder.changed still emit on the SSE bus in src/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.

commented

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: publish today 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 /ws and /events split that #40 decides is honoured here: per-frame state on the socket, round.started / round.ended / ladder.changed on 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs