Server-sent events for a live simulation
Simulation frames go out over server-sent events, which are one-way, plain HTTP and reconnecting for free, because nothing a watcher does needs a socket and the little that does is an ordinary POST.
- Transport
- SSE, one direction
- Rooms
- broadcast no faster than every 250 ms
- Village
- a compact frame 4× a second
- On the wire
- the last 48 ticker lines, not the whole log
Why not websockets
The traffic is almost entirely one-way. The server has news; the watcher has almost nothing to say. A watcher's whole input surface is picking a room, toggling detail, clicking a person, and voting in the village, all of which are fine as ordinary requests.
In exchange, SSE gives you reconnection semantics for free, no upgrade handshake, no sticky-session thinking, and a stream you can read with curl while debugging.
Frames are cheap on purpose
The log the engine keeps is capped at four thousand events so that a saved party is a complete transcript, but only the last forty-eight lines ever go over the wire. Broadcasts coalesce to no more than one every 250 ms even when several people act in the same instant.
The village sends a compact positional frame four times a second and the browser interpolates. The roster, which is every person as a sortable table, is a separate endpoint polled only while it is open, because most watchers never open it.
Make the clock survive a lumpy connection
lib/clock.ts extrapolates the party clock between frames from the last server value and wall time. Without it a slow connection reads as people teleporting; with it, it reads as people walking and a countdown that keeps counting.
That is the general trick for a streamed simulation: send the truth rarely, and let the client be confidently wrong about the microseconds.
Keep proxies off it
Every /api/* response carries X-Robots-Tag: noindex, and the stream must not be buffered or cached by anything in front of it. A CDN that helpfully buffers an event stream turns a live room into a two-minute-old room, which is worse than an error because it looks like it is working.
Read next
One party in memory, streamed to everyone
A room is a single party held in server memory and streamed to every watcher, so everybody sees the same betrayal at the same moment and the model is paid for once per beat however many people are looking.
Why this app is pinned to one replica
The party lives in server memory and every watcher of a room shares it, so two replicas means one URL serving two different parties. railway.json pins numReplicas to 1 as a correctness constraint, not a cost saving.
Two clocks: real time, not turns
Characters act on their own clocks. Each one wakes when its cadence comes round, makes its call, and moves the instant the answer lands, while the slow physics and the room's decisions run on beat boundaries.
The 3D layer never feeds back
The browser receives zones and activities and decides everything about bodies: where they walk, which way they face, what gesture they make. Nothing in the rendering layer may write back to the simulation except a click to select.