The room server behind group rides in OpenCycle.
One Go process. It relays positions between riders sharing a road, and does nothing else — no accounts, no database, no persistence.
go run ./cmd/roomd # http://localhost:8080
go test ./... -race # unit, end-to-end and load
docker build -t roomd . && docker run -p 8080:8080 roomdOpenCycle generates its world procedurally in (arc length, lateral offset)
space, which means a rider's position is a single float: s, metres
travelled along the centerline. The road supplies everything else — the XYZ
position, the heading, the gradient — as a pure function of s.
Three consequences, and they are the whole design:
- A position update is ~60 bytes.
{s, v}and a timestamp. - Dead reckoning is exact.
s + v·dt, in one dimension, on an object with the mass and acceleration profile of a bicycle. A rider holding a steady pace is predicted to within centimetres over a full second, so clients send corrections rather than a fixed tick — typically well under 1 Hz. - There is no state worth persisting. A room is a world key, a seed, and a roster. Nobody expects yesterday's ride to still be joinable.
So the server has no work to do beyond fan-out, and one modest instance carries
tens of thousands of concurrent riders. That capacity claim is asserted by
TestConcurrentRoomsHoldUp rather than assumed.
A room is created with a world, a seed and a build, and can never change any of them. That single constraint removes host migration, mid-ride world reloads, and every question of the form "what happens when the host leaves". Changing world means leaving the room.
Rooms hold at most 8 riders and are swept 15 minutes after the last one leaves.
OpenCycle's generation is deterministic for a given (world, seed) on a given
build — but retuning a generator changes the geometry, which is exactly what
its golden-hash test exists to catch. Two riders on different deploys would
generate visibly different worlds from identical inputs, and it would present as
a friend riding through trees that are not there.
So a room pins the build that created it and refuses mismatches with
build_mismatch, naming the version it wants so the client can say which side
is stale.
POST /v1/rooms |
{world, seed, build} → {id, world, seed, build} |
GET /v1/rooms/{id} |
→ {id, world, seed, build, riders} |
GET /v1/rooms/{id}/socket |
WebSocket upgrade |
GET /healthz |
→ {ok, rooms} |
Room ids are 16 characters of base32 over 80 bits of entropy. The link is the access control — there are no accounts — so ids must stay unguessable, and base32 keeps them readable when somebody pastes one into a chat.
All frames are JSON with a t discriminator.
Client → server
Server → client
{"t":"welcome","you":"9f3a...","room":{...},"peers":[...],"startS":12480.5,"now":...}
{"t":"joined","rider":{"id":...,"name":...,"slot":2,...}}
{"t":"state","id":"9f3a...","s":12480.5,"v":7.25,"at":1743019200456}
{"t":"left","id":"9f3a..."}
{"t":"pong","c":1743019200123,"now":1743019200130}
{"t":"error","code":"build_mismatch","message":"...","wantBuild":"a1b2c3d4"}Error codes: no_room, room_full, build_mismatch, bad_message,
rate_limit.
atandnoware server-clock milliseconds. Clients solve for their offset fromping/pongand extrapolate against the shared clock. Without it, "40 m ahead" is a claim about simultaneity that nobody agrees on — half a second of skew is 3.5 m of position error at club pace.slotis a lane index, 0-based and stable for the life of a connection. The client turns it into a lateral offset so riders sit side by side rather than inside each other. Slots are freed and reissued on leave.startSplaces a joiner alongside the rider who is furthest along, not at the start of a road everyone else left twenty minutes ago.appearanceis opaque. The server relays it as bytes and caps it at 512. It is the client's schema, and keeping it opaque is what stops this service needing a deploy every time the customiser gains an option.- State is relayed immediately, not batched into a fixed-rate snapshot. Clients already send only when their own dead reckoning has drifted, so the inbound stream is sparse and irregular; batching would add latency to exactly the corrections worth sending and put a message floor under rooms where everybody is holding a steady pace.
-addr listen address (default :8080, or $ADDR)
-origins comma-separated allowed origins (default none, or $ORIGINS)
-ttl how long an empty room survives (default 15m)
-sweep how often to look for expired rooms (default 1m)
Set -origins in production. Empty means any website may open rooms against
this server, which is the right default for go run on a laptop and the wrong
one everywhere else.
roomd -origins https://opencycle.org,https://www.opencycle.orgThere is no TLS here on purpose — terminate it at the load balancer, which is where the certificate lifecycle already lives.
Riders in the same room must reach the same process. Consistent hashing on the room id at the load balancer is the entire change; nothing in here has to become distributed.
The next lever after that, if it is ever needed, is aggregating relays into a fixed-rate snapshot per room — trading up to a tick of latency (invisible under dead reckoning) for a bounded outbound rate. It is deliberately not done yet, because at present inbound is already sparser than any tick worth choosing.
AGPL-3.0-or-later, matching OpenCycle itself.
{"t":"hello","name":"Alex","build":"a1b2c3d4","appearance":{...}} {"t":"state","s":12480.5,"v":7.25} // metres, metres/sec {"t":"ping","c":1743019200123} // client's own clock