Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

9 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

OpenCycle API

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 roomd

Why it is this small

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

Rooms are immutable

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.

The build field is load-bearing

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.

HTTP

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.

WebSocket

All frames are JSON with a t discriminator.

Client → server

{"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

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.

Details that matter

  • at and now are server-clock milliseconds. Clients solve for their offset from ping/pong and 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.
  • slot is 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.
  • startS places a joiner alongside the rider who is furthest along, not at the start of a road everyone else left twenty minutes ago.
  • appearance is 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.

Running it

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

There is no TLS here on purpose — terminate it at the load balancer, which is where the certificate lifecycle already lives.

Scaling past one process

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.

Licence

AGPL-3.0-or-later, matching OpenCycle itself.

About

Room server for OpenCycle group rides

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages