Gateway
The Gateway is LocalUniverse's WebSocket protocol for real-time events: messages arriving, presence changing, equipment updating, channels mutating. One socket per client receives everything the user is subscribed to.
It's a separate Cloudflare Workers deployment from the REST API, backed by Durable Objects. Auth happens at the upgrade — there is no IDENTIFY round-trip on this protocol. The server validates the token via a service binding to the API, then HELLO + READY fire immediately.
Connect
wss://gateway.localuniverse.io/v1?v=1&token=<token>
- Name
v- Type
- integer
- Description
Protocol version. Must equal
GATEWAY_VERSION(currently1) — anything else is rejected at the upgrade with400.
- Name
token- Type
- string
- Description
Same bare user-session token used on REST. Lives in the query string because browsers can't set headers on a WebSocket upgrade. Invalid / revoked tokens get
401at the upgrade.
- Name
resume- Type
- string
- Description
Optional. Existing
session_idto reattach to.
- Name
last_seq- Type
- integer
- Description
Required when
resumeis set. Last sequence number the client successfully processed.
Browser
const ws = new WebSocket(
`wss://gateway.localuniverse.io/v1?v=1&token=${token}`
);
Payload shape
Every frame in either direction shares one envelope:
{
"op": 0,
"d": {},
"s": 1,
"t": "MESSAGE_CREATE"
}
- Name
op- Type
- integer
- Description
Opcode. See the opcode table.
- Name
d- Type
- any
- Description
Event-specific data.
- Name
s- Type
- integer
- Description
Sequence number. Set on dispatch frames (
op: 0); used byRESUMEto replay. Omitted on non-dispatch frames.
- Name
t- Type
- string
- Description
Event name. Set on dispatch frames (
op: 0); omitted otherwise. See Gateway events for the full list.
Opcodes
| Op | Name | Direction | Notes |
|---|---|---|---|
0 | DISPATCH | server → client | Event push. t carries the event name. |
1 | HEARTBEAT | bidirectional | Client sends every heartbeat_interval ms. |
2 | IDENTIFY | client → server | Reserved; the current protocol authenticates at the upgrade and skips this. |
3 | PRESENCE_UPDATE | client → server | Update the caller's presence status. |
4 | SUBSCRIBE_CHANNEL | client → server | Subscribe to a channel topic — messages, typing, channel updates. |
5 | UNSUBSCRIBE_CHANNEL | client → server | Drop a channel subscription. |
6 | TYPING | client → server | Start a typing indicator in a channel. |
7 | RESUME | client → server | Reattach using a prior session_id + last_seq. |
8 | WARP_UPDATE | client → server | Position / pose update for the in-world avatar. |
10 | HELLO | server → client | First frame the server sends. Carries heartbeat_interval. |
11 | HEARTBEAT_ACK | server → client | Acknowledges a client heartbeat. |
12 | INVALID_SESSION | server → client | Sent during a failed RESUME to tell the client whether it can retry. |
Connection lifecycle
1. Upgrade
Client opens the WebSocket with ?v=1&token=.... The Gateway validates the token by calling /v1/auth/verify on the API via service binding. On failure the upgrade returns 401.
2. HELLO
The server immediately dispatches:
{ "op": 10, "d": { "heartbeat_interval": 30000 } }
heartbeat_interval is the cadence the client must keep, in milliseconds. The server times out a socket whose last activity is older than 90_000 ms (three missed heartbeats).
3. READY
For a fresh connection (no ?resume), the server dispatches READY next:
{
"op": 0,
"s": 1,
"t": "READY",
"d": {
"user": { "id": "...", "email": "...", "display_name": "...", ... },
"session_id": "31293...",
"heartbeat_interval": 30000
}
}
The user field is the same shape REST returns from /v1/users/@me — clients can hydrate the entire app state from this one frame.
Persist session_id immediately. If the connection drops, RESUME uses it to replay missed events.
4. Heartbeat loop
{ "op": 1 }
Client sends every heartbeat_interval ms. Server replies { "op": 11 }. Both sides treat lack of activity for 3 × interval as a dead connection.
Resume
If the socket drops and the client still has session_id + the last s it processed, reconnect with ?resume:
wss://gateway.localuniverse.io/v1?v=1&token=...&resume=31293...&last_seq=4287
The Gateway routes to the same Durable Object instance that owned the original socket. If the DO still has the in-memory buffer and your last_seq falls inside it, you get RESUMED followed by a replay of every event with s > last_seq. Once the replay drains, normal dispatch resumes.
If the DO has been evicted, or the buffer doesn't reach back to last_seq, the server sends:
{ "op": 12, "d": { "resumable": false } }
The client should drop the cached session_id, reconnect without ?resume, and re-hydrate from READY.
Subscriptions
A client only receives events for resources it has subscribed to. The Gateway maintains per-channel subscriber sets in a ChannelTopic Durable Object.
Subscribe to a channel. d is the channel id as a snowflake string. Once subscribed you receive MESSAGE_CREATE, MESSAGE_UPDATE, MESSAGE_DELETE, TYPING_START, and CHANNEL_UPDATE for that channel.
SUBSCRIBE_CHANNEL
{ "op": 4, "d": "3123..." }
Drop the subscription. The server forgets about you for that channel; nothing else is required.
UNSUBSCRIBE_CHANNEL
{ "op": 5, "d": "3123..." }
User-scoped events (USER_UPDATE, EQUIPMENT_UPDATE, INVENTORY_ADD, INVENTORY_REMOVE, RELATIONSHIP_UPDATE) are auto-delivered without an explicit subscribe — they fan out to every live session for the affected user via the UserPresence DO.
Close codes
| Code | Meaning | Reconnect behavior |
|---|---|---|
4001 | Expected IDENTIFY got something else | Library bug — investigate before retry |
4004 | Authentication failed | Refresh the token and reconnect |
4008 | IDENTIFY / first message timeout | Transient — retry with backoff |
4009 | Session expired | Reconnect (token may still be valid) |
1xxx codes follow the WebSocket spec (1000 normal close, 1011 server error). Well-behaved clients should distinguish "permanent close, log the user out" from "transient close, retry with backoff" — only 4004 and a deliberate user-initiated 1000 should drop the user back to login.