match
Matchmaking Worker served on the match subdomain (match.recflare.net) — a Hono app
that handles player presence and places players into room instances. Rooms, room
instances and presence all live in the shared recflare D1 database.
Routes
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /player/login |
Login ack (no-op; must not touch presence) | |
| POST | /player/exclusivelogin |
Exclusive-login ack (no-op) → { errorCode: 0 } |
|
| POST | /player/logout |
✓* | Clear presence (except the Orientation seed) |
| POST | /player/notifydisconnect |
Disconnect notification (no-op ack) | |
| GET | /player?id=1&id=2,3 |
Batch player presence lookup | |
| POST | /player/heartbeat |
✓ | Presence heartbeat (JSON body) |
| PUT | /player/statusvisibility |
✓* | Set status visibility |
| POST | /goto/room/:room |
✓ | Go to a room (dormroom → personal dorm) |
| POST | /matchmake/none |
Preserve current instance, else dorm | |
| POST | /matchmake/room/:roomId/:subRoomId |
✓ | Matchmake into a specific subroom |
| POST | /matchmake/room/:roomId |
✓ | Matchmake into a room (default subroom) |
| POST | /matchmake/:room |
✓ | Matchmake by id or name (dorm → personal dorm) |
| POST | /goto/none |
Go to the dorm | |
| PUT | /player/photonregionpings |
Region ping report (no-op ack) | |
| PUT | /player/gameserverregionpings |
Region ping report (no-op ack) | |
| POST | /roominstance/:id/reportjoinresult |
Report join result (no-op ack) | |
| PUT | /roominstance/:id/inprogress |
✓ | Set the instance's in-progress flag |
| GET | /room/:roomId/instances |
✓ | A room's live instances (owner/co-owner only) |
| GET | /rooms/requiring/developer |
Rooms requiring a developer → [] |
|
| GET | /rooms/requiring/rrplus |
Rooms requiring RR+ → [] |
|
| GET | /openapi.json |
Generated OpenAPI 3.1 spec (see below) |
* logout and statusvisibility read the token when present but never 401 — an
unauthenticated call is a no-op ack. The other ✓ routes return an empty-body 401 when
the Bearer JWT (issued by the auth worker) is missing or invalid.
API documentation
GET /openapi.json serves a spec generated from describeRoute blocks that sit
alongside each handler, with the schemas in src/openapi.ts.
The spec is descriptive, not enforced — same rationale as the auth/accounts
workers: a reverse-engineered protocol, lenient handlers, no runtime validation. A test
asserts every route appears in the spec, so adding one without documenting it fails.
Presence
Presence is a per-player row in the shared presence table recording the room instance
that player is currently in, plus status fields (visibility, device class, VR movement
mode, platform, app version). It's written by matchmake/goto and refreshed by the
heartbeat, and read by the heartbeat and the batch GET /player.
isOnlinemeans "has a live presence row", not "is in a room". Rows expire on a TTL, so a player who stops heartbeating drops offline; a player can be online in the lobby withroomInstancenull.- The heartbeat is write-thrifty. An unchanged heartbeat re-writes the row (to
extend its TTL) only once the TTL is within
PRESENCE_REFRESH_THRESHOLDseconds of lapsing — a still player is refreshed periodically rather than on every beat. - A cron sweep (
scheduled) clears presence past its TTL and, crucially, recomputes the fullness of the instances those rows pointed at. Nothing else notices a player who crashed or hard-quit, so without the sweep their instance can stay flagged full — and unjoinable — with nobody in it.
The heartbeat also accepts a non-JSON (LoginLock form) body, which it reads and ignores; only a JSON body carries status fields.
Matchmaking and room instances
A matchmake resolves the room (by numeric id or name) from D1, then finds a joinable
public instance of the requested subroom or creates a new room_instance. The result
is persisted as the player's presence so the heartbeat can replay it — keeping the
client's local presence in sync. errorCode 0 with a roomInstance is success; an
unknown room returns errorCode 20 (NoSuchRoom) with roomInstance: null.
Several behaviours are load-bearing and reverse-engineered from the client:
- Instance names are
^-prefixed so the client resolves the new scene; personal dorms are the exception (@owner's Dorm, no^). An emptylocation(the SubRoom's Unity scene id) makes the client reject the session. - Never re-place a player into their current instance. The client keys the room
transition off a changing
roomInstanceId; returning the same id hangs it mid-join, so the join search excludes the caller's current instance. - Subrooms are separate places. Joining one must never land you in an instance of
another, so instance reuse is scoped to the exact
(roomId, subRoomId). - The dorm is a single stable instance with a constant Photon room id, returned identically by every dorm entry point and the heartbeat, so the client's whole-instance presence check never reads out-of-sync.
- Two dorm keywords:
goto/room/dormroomandmatchmake/dorm— different spellings the 2023 client uses for the same destination. matchmake/nonepreserves existing presence (it's how the client establishes the solo Orientation room) and only falls back to the dorm when the player has none.goto/nonealways goes to the dorm.
Switching a room out (ROOM_REDIRECTS)
An operator can substitute one room for another at matchmake time — the way to replace a
stock RRO room, typically the Rec Center (room 2), with a room of their own without
touching the client. The knob is RECFLARE_ROOM_REDIRECTS in the root .env (see
.env.example), comma-separated <fromRoomId>=<to> pairs where <to> is a room id or
name: 2=MyHub, or 2=100,3=MyHub.
Substitution happens where a matchmake resolves a named room, so it covers every route
that names one — the two- and three-segment room matchmakes and a club's clubhouse — and
everything downstream (the ban check, presence, the visit count) sees only the room
actually entered. Matching is on the resolved room id, so asking by name (RecCenter)
substitutes the same as asking by id.
- A requested subroom is dropped when a substitution fires: the id addresses a subroom of the room the client asked for, so entry falls back to the substitute's default one.
- One hop only —
2=3,3=2swaps the two rooms rather than looping. - An unresolvable target leaves the original room in place (logged), so a typo doesn't make a room unreachable.
- Following a friend and joining a specific instance are unaffected — those enter a live instance, which is already in whichever room it was created in.
Bindings
| Binding | Type | Notes |
|---|---|---|
DB |
D1 | Shared recflare database — rooms, room instances, presence |
JWT_SECRET |
Secrets Store | Shared HS256 signing key (see the auth README) |
The presence and room_instance tables are owned/migrated by the rooms worker;
this worker has no migrations of its own.
Known gaps
/rooms/requiring/developerand/rooms/requiring/rrplusalways return[]— no such gating queue exists yet.