@genex-ai/multiplayer 0.16.2 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PROTOCOL.md +624 -0
- package/README.md +111 -21
- package/dist/bridge.d.ts +89 -0
- package/dist/bridge.global.js +14 -0
- package/dist/bridge.js +558 -0
- package/dist/chunk-C52XFIB6.js +312 -0
- package/dist/chunk-CTDU4IJ5.js +2191 -0
- package/dist/index.d.ts +110 -18
- package/dist/index.js +7 -1809
- package/dist/protocol.d.ts +419 -0
- package/dist/protocol.js +58 -0
- package/dist/schema.d.ts +122 -68
- package/dist/schema.js +95 -16
- package/package.json +27 -10
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
# Genex relay protocol, version 3
|
|
2
|
+
|
|
3
|
+
This document is the normative contract between the Genex multiplayer relay (`apps/colyseus`) and every client that joins it: the TypeScript SDK in this package and the Rust, Godot (GDScript) and Unity (C#) clients in the repository's `sdks/`. It is written so that someone holding only an official Colyseus 0.18 SDK for their engine can write a conforming client.
|
|
4
|
+
|
|
5
|
+
The machine-readable copy of every name, number and limit below is [`src/protocol.ts`](src/protocol.ts) (published as `@genex-ai/multiplayer/protocol`) and [`src/schema.ts`](src/schema.ts) (`@genex-ai/multiplayer/schema`). The relay imports both, so when this document and those files disagree, the code is what runs. Fix the disagreement in the same change.
|
|
6
|
+
|
|
7
|
+
## 1. Conventions
|
|
8
|
+
|
|
9
|
+
The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119.
|
|
10
|
+
|
|
11
|
+
- **Transport.** Colyseus 0.18 over WebSocket: HTTP matchmaking, seat reservation, then a room socket. Room messages are a string type plus a msgpack payload, and room state is `@colyseus/schema` 5 with delta patches. When the relay publishes its clock (§16), every state frame also carries Colyseus's TIMED prefix, which the official SDKs decode. A client uses its engine's official Colyseus SDK for all of this and never reimplements the wire.
|
|
12
|
+
- **The relay is not a game server.** It runs no simulation, prediction, reconciliation or lag compensation, and no per-game code. Games are client- and host-authoritative. The relay enforces only what a client cannot be trusted with: who is in the room, who owns an object, who the host is, rate and size limits, chat and voice identity, and the match state machine.
|
|
13
|
+
- **Drop, don't error.** A message the relay will not act on is dropped silently. That covers malformed, oversized, unauthorised and over-budget messages. Only three things ever answer a client:
|
|
14
|
+
- an `__object:result` for a control request it could answer;
|
|
15
|
+
- `__voice:rejected` when voice is full;
|
|
16
|
+
- `rate:drop` notices.
|
|
17
|
+
|
|
18
|
+
The relay never kicks a client for a payload it could decode. Two things close the socket with 4002, and neither gets a reconnection grace (§8): Colyseus's coarse `MAX_MESSAGES_PER_SECOND` (1200/s) ceiling, and a frame whose msgpack cannot be decoded at all (truncated bytes, an extension msgpackr does not know).
|
|
19
|
+
- **Payload encoding.** Payloads are msgpack maps with string keys. Every number MUST be a float64, or an integer encoded in at most 32 bits. The relay's decoder turns msgpack int64/uint64 into BigInt, and the relay drops any payload containing one, in any key, including keys no handler reads. Many Rust and C# msgpack encoders pick int64 for large integers by default, so configure yours. Payloads MUST be finite JSON-shaped trees, meaning no NaN, no Infinity and no binary, at most 32 levels deep and at most 4096 nodes; the relay checks the whole payload before any handler sees it and drops one that is not. An unset optional key MAY arrive as msgpack's `undefined` extension (what msgpackr writes); it reads as an absent key. "JSON text" below means a msgpack **string** that holds UTF-8 JSON.
|
|
20
|
+
- **Sizes** are JavaScript string lengths (UTF-16 code units). A client that keeps the UTF-8 byte length under a limit is always within it.
|
|
21
|
+
|
|
22
|
+
## 2. Versioning
|
|
23
|
+
|
|
24
|
+
- The protocol version is `PROTOCOL_VERSION = 3`. A client MUST send it as the `protocol` join option on every join, create, joinById and queue join.
|
|
25
|
+
- The relay accepts `[MIN_PROTOCOL_VERSION, PROTOCOL_VERSION]`, which is `[3, 3]` today. Any other value, or a missing option, is refused before a room is found or created. The refusal is matchmaking error code **426** with the message `unsupported protocol: <sent>, relay speaks <min>..<max>`. A client receiving 426 MUST NOT retry and SHOULD tell the player the game needs an update.
|
|
26
|
+
- This is the only version check. There is no capability handshake and no per-feature negotiation. Protocol 2 (`__v`), its JSON mirrors under `__obj:`/`__host`/`__match`/`__voice`, and its legacy claim forms are not served.
|
|
27
|
+
- These changes are not version bumps: appending a field to the end of a schema, adding a message type, and adding an optional payload key. Clients MUST ignore unknown message types, unknown payload keys and unknown match-state fields.
|
|
28
|
+
- These changes are version bumps: inserting, removing, reordering or retyping a schema field; reshaping a payload; changing a semantic in this document.
|
|
29
|
+
|
|
30
|
+
## 3. Connection and identity
|
|
31
|
+
|
|
32
|
+
### 3.1 Relay URL
|
|
33
|
+
|
|
34
|
+
The embed layer supplies a list of regional relay URLs (`getColyseusUrls()` from `@genex-ai/embed-sdk`; the API returns it as `colyseus.urls` with the embed session).
|
|
35
|
+
- With one URL, the client uses it.
|
|
36
|
+
- With several, it SHOULD measure latency to each (Colyseus `getLatency` / `Client.selectByLatency`) and use the lowest.
|
|
37
|
+
- An explicit invite URL (a friend's region) MUST override selection. Rooms exist per relay, so two players share a room only if they share a URL.
|
|
38
|
+
|
|
39
|
+
A client MUST keep using the chosen URL for the life of a session, across reconnects and seat reservations.
|
|
40
|
+
|
|
41
|
+
### 3.2 Embed token
|
|
42
|
+
|
|
43
|
+
Every join carries `embedToken`, an `embed:play` JWT (audience `embed`). It lives about 15 minutes and rotates about every 10.
|
|
44
|
+
- In a browser, wait for identity to settle (`waitForPlayer()`, which settles for guests as well as signed-in players), then read `getColyseusAuth().embedToken`.
|
|
45
|
+
- A client MUST read a fresh token immediately before every join, create, joinById and queue join, and MUST NOT cache one across those calls.
|
|
46
|
+
- Reconnection (§8) and seat consumption (§9) do not need a token.
|
|
47
|
+
- A client MUST NOT log or persist the token.
|
|
48
|
+
|
|
49
|
+
Native, non-browser clients obtain the same token through the API's native identity flows, which are specified separately. The relay sees only the token.
|
|
50
|
+
|
|
51
|
+
The relay verifies the token's signature and its `slug` claim (it MUST equal `gameId`). A token whose `channel` claim is `staging` (a preview build) is filed under the room key `preview--<slug>` (`roomKeyFor`, `STAGING_ROOM_PREFIX`), so preview testers play together in rooms the live game never sees. The client still sends the plain slug; the relay rewrites `gameId` itself. A production token cannot ask for a `preview--` room. For signed-in players it also checks revocation, at join and every 30 s afterwards. A revoked session is closed with **4401**.
|
|
52
|
+
|
|
53
|
+
### 3.3 uid: the stable player id
|
|
54
|
+
|
|
55
|
+
`PlayerState.uid` is the player's stable id **within this game**. It is the same across reloads, devices and rooms of the game, different in every other game, and never the account id.
|
|
56
|
+
- Games MUST key anything that outlives a session (scores, colours, saves, "is this me") by uid, never by session id, because a reload gets a new session id.
|
|
57
|
+
- No two seated players in a room share a uid.
|
|
58
|
+
- It is opaque, at most 64 characters, and clients MUST NOT parse it.
|
|
59
|
+
|
|
60
|
+
The relay stamps it at join from the verified token, in this order:
|
|
61
|
+
1. **The token's `uid` claim**, if present: a per-(user, project) HMAC minted by the API.
|
|
62
|
+
2. **Guests:** the token's `sub` (`guest:<uuid>`). It is stable for as long as that browser keeps its guest session.
|
|
63
|
+
3. **Fallback** (a signed-in token without the claim): `p:` followed by 22 random base64url characters, fresh for this seat. It is deliberately not derived from `sub`: account ids are public, so any unkeyed function of one is recomputable by every peer.
|
|
64
|
+
|
|
65
|
+
Deployment note: the API's `uid` claim ships with this relay, so the fallback serves only tokens minted before the claim existed (at most one token lifetime). A player on such a token gets a uid that lasts only for that seat; the next refreshed token carries the stable claim.
|
|
66
|
+
|
|
67
|
+
### 3.4 Join options and refusals
|
|
68
|
+
|
|
69
|
+
| Option | Type | Notes |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| `gameId` | string | The game's slug. Isolates rooms (`filterBy`). MUST equal the token's `slug`. |
|
|
72
|
+
| `embedToken` | string | Required (§3.2). |
|
|
73
|
+
| `name` | string | Display-name fallback, truncated to 64. The token's name wins when it has one. |
|
|
74
|
+
| `protocol` | number | Required (§2). |
|
|
75
|
+
|
|
76
|
+
The options `clients`, `preset`, `winCondition`, `config` and `matchmade` are server-owned, and sending any of them is refused. Other keys are ignored.
|
|
77
|
+
|
|
78
|
+
| Code | Meaning |
|
|
79
|
+
|---|---|
|
|
80
|
+
| 400 | A server-owned option was sent. |
|
|
81
|
+
| 401 | `auth required`, `invalid token` or `session revoked`. Get a fresh token; if it repeats, the player must sign in again. |
|
|
82
|
+
| 403 | `wrong game` or `guest capacity` (the room's guest cap is full). |
|
|
83
|
+
| 426 | Unsupported protocol (§2). |
|
|
84
|
+
|
|
85
|
+
### 3.5 Room types
|
|
86
|
+
|
|
87
|
+
| Type | How a client enters | What it is |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `play` | `joinOrCreate('play', options)` | The plain relay room. Placement prefers the fullest open room of the game. At most 64 seats; when full, the next join creates a sibling room. |
|
|
90
|
+
| `queue` | `joinOrCreate('queue', options)` | The matchmaking search pool. It has no game state; it hands out seat reservations (§9). |
|
|
91
|
+
| `match` | A seat reservation from the queue; `create('match', options)` for a private lobby; `joinById(code, options)` with an invite code | A capped match arena: everything `play` does, plus the match state machine (§9). |
|
|
92
|
+
|
|
93
|
+
### 3.6 One seat per identity
|
|
94
|
+
|
|
95
|
+
A player holds at most one seat per room. A new join by the same identity (a second tab, or a reloaded page) takes the seat over:
|
|
96
|
+
- If the old session is inside its reconnection grace, that grace is cancelled.
|
|
97
|
+
- Otherwise the old session's socket is closed with **4409** (superseded).
|
|
98
|
+
|
|
99
|
+
A client that receives 4409 MUST NOT reconnect or rejoin automatically, because another instance of the player is now playing.
|
|
100
|
+
|
|
101
|
+
## 4. Room state
|
|
102
|
+
|
|
103
|
+
All state is written by the relay alone. Clients change it only by sending messages (§5), which the relay validates first. Field order is wire order (see `src/schema.ts`). The relay writes every field at creation, so every field decodes to a defined value in every engine.
|
|
104
|
+
|
|
105
|
+
**`RoomState`** (root)
|
|
106
|
+
|
|
107
|
+
| # | Field | Type | Meaning |
|
|
108
|
+
|---|---|---|---|
|
|
109
|
+
| 0 | `players` | map<string, PlayerState> | Seated players, keyed by session id. |
|
|
110
|
+
| 1 | `shared` | map<string, string> | Game-writable key → JSON text (§6.2). |
|
|
111
|
+
| 2 | `objects` | map<string, ObjectState> | Shared objects, keyed by object id (§7). |
|
|
112
|
+
| 3 | `host` | string | Session id of the elected host, or `''` (§6.3). |
|
|
113
|
+
| 4 | `match` | string | Match rooms: the match state as JSON text (§9.3). Play rooms: `''`. |
|
|
114
|
+
| 5 | `voice` | map<string, boolean> | Voice roster: session id → `true`. Membership is key presence (§13). |
|
|
115
|
+
| 6 | `clockOrigin` | float64 | The relay's wall clock (epoch ms) at room-clock zero, written once at creation. Maps epoch stamps onto the server clock (§16.3). `0` = the relay does not publish its clock. |
|
|
116
|
+
|
|
117
|
+
**`PlayerState`**
|
|
118
|
+
|
|
119
|
+
| # | Field | Type | Meaning |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| 0 | `name` | string | Verified display name, or the `name` option; at most 64. |
|
|
122
|
+
| 1 | `data` | string | The player's own JSON object, at most 16 KiB, including `__t` (§6.1). Initially `{}`. |
|
|
123
|
+
| 2 | `epoch` | number | Incremented by `__state:snap`. A change means "discard history" (§10). |
|
|
124
|
+
| 3 | `connected` | boolean | `false` while the seat is held only by reconnection grace (§8). |
|
|
125
|
+
| 4 | `avatarUrl` | string | Verified VRM avatar URL from the token, or `''`. |
|
|
126
|
+
| 5 | `uid` | string | Stable per-game player id (§3.3). |
|
|
127
|
+
| 6 | `team` | number | Team index, or `-1` for no team (§9.4). |
|
|
128
|
+
| 7 | `x` | float32 | Optional typed pose (§6.6): position. `0` until a pose is published. |
|
|
129
|
+
| 8 | `y` | float32 | Pose position. |
|
|
130
|
+
| 9 | `z` | float32 | Pose position. |
|
|
131
|
+
| 10 | `yaw` | float32 | Pose heading in radians, any finite value. Interpolated along the shortest arc. |
|
|
132
|
+
| 11 | `vx` | float32 | Pose velocity, world units per second (the sender's own). |
|
|
133
|
+
| 12 | `vy` | float32 | Pose velocity. |
|
|
134
|
+
| 13 | `vz` | float32 | Pose velocity. |
|
|
135
|
+
| 14 | `anim` | string | Pose animation/state name, at most 64. Discrete: never interpolated. `''` = none. |
|
|
136
|
+
| 15 | `poseT` | float64 | The sender's clock stamp (ms, the `__t` clock) of the pose, written in the same patch. `0` = no pose yet. |
|
|
137
|
+
| 16 | `posed` | boolean | `true` from the seat's first accepted pose until the seat is gone. Selects §10.1 over §10 for this player. |
|
|
138
|
+
|
|
139
|
+
**`ObjectState`**
|
|
140
|
+
|
|
141
|
+
| # | Field | Type | Meaning |
|
|
142
|
+
|---|---|---|---|
|
|
143
|
+
| 0 | `owner` | string | Owner session id, or `''` when unowned. |
|
|
144
|
+
| 1 | `seq` | number | +1 on every owner change (claim, release, reassignment, failover). It never changes on a publish. |
|
|
145
|
+
| 2 | `epoch` | number | Incremented by `__object:snap`. A change means "hard reset". |
|
|
146
|
+
| 3 | `t` | float64 | The current owner's send stamp (ms) for `d`. Set to `0` on every owner change. |
|
|
147
|
+
| 4 | `d` | string | The object's JSON state object, at most 64 KiB. `''` until the first publish. Preserved across owner changes. |
|
|
148
|
+
|
|
149
|
+
`players` entries exist from join until the seat is truly gone. `objects` entries exist from the first accepted claim until `destroy`. `shared` keys are never removed.
|
|
150
|
+
|
|
151
|
+
## 5. Messages
|
|
152
|
+
|
|
153
|
+
### 5.1 Rate lanes
|
|
154
|
+
|
|
155
|
+
Every inbound message spends one token from one lane, and a message that finds its lane empty is dropped:
|
|
156
|
+
|
|
157
|
+
| Lane | Rate/s | Burst | Spent by |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `state` | 30 | 60 | `state`, `__state:snap` |
|
|
160
|
+
| `control` | 40 | 80 | `claim`, `release`, `destroy`, `__room:open`, `__chat:history:request` |
|
|
161
|
+
| `input` | 60 | 120 | `__input` |
|
|
162
|
+
| `bulk` | 120 | 240 | `shared`, `obj`, `__object:snap`, `match:*`, game events, anything unhandled |
|
|
163
|
+
| `chat` | 2 | 5 | `__chat` |
|
|
164
|
+
| `voice` | 25 | 60 | `__voice:*` |
|
|
165
|
+
| `pose` | 30 | 60 | `__pose`, `__pose:snap` |
|
|
166
|
+
|
|
167
|
+
- `bulk` and `input` also share a global bucket (180/s, burst 360).
|
|
168
|
+
- `bulk` starts dropping at random once fewer than 60 tokens remain, so the loss spreads across all of a client's streams instead of starving the last one each tick.
|
|
169
|
+
- The `state`, `control`, `chat`, `voice` and `pose` lanes sit outside the global bucket. Object traffic can therefore never starve a player's own avatar, controls or chat, and a posed player's `data` and pose never starve each other.
|
|
170
|
+
- Clock heartbeats (§16) are Colyseus input frames, not room messages. They spend no lane and earn no `rate:drop`; only the socket ceiling (§1) counts them.
|
|
171
|
+
- A client whose messages were dropped receives `rate:drop {dropped: number}` at most once per second. It SHOULD surface it to the developer and publish less: idle objects at a keepalive of 2 Hz or less, never every frame.
|
|
172
|
+
|
|
173
|
+
### 5.2 Client → relay
|
|
174
|
+
|
|
175
|
+
| Type | Payload | Rooms | Effect |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| `state` | JSON text of an object (at most 16 KiB) | play, match | Replaces `PlayerState.data` (§6.1). |
|
|
178
|
+
| `__state:snap` | as `state` | play, match | Replaces `data` and increments `epoch` in the same patch. |
|
|
179
|
+
| `shared` | `{key, value}`: key at most 256 chars, no `__` prefix; value is JSON text, at most 64 KiB | play, match | Sets `shared[key]`. New keys past 256 per room are dropped. |
|
|
180
|
+
| `claim` | `{id, requestId, authority?: 'host'}` | play, match | Ownership request; answered by `__object:result` (§7). |
|
|
181
|
+
| `release` | `{id, requestId}` | play, match | Owner gives the object up (§7). |
|
|
182
|
+
| `destroy` | `{id, requestId}` | play, match | Owner deletes the object (§7). Results name this operation `remove`. |
|
|
183
|
+
| `obj` | `{id, d: object, t: number}` | play, match | Owner publishes object state (§7.3). |
|
|
184
|
+
| `__object:snap` | as `obj` | play, match | Owner publishes and increments `epoch` (a reset or teleport). |
|
|
185
|
+
| `__input` | any JSON value, at most 16 KiB encoded | play, match | Forwarded to the current host only (§6.4). |
|
|
186
|
+
| `__room:open` | `{open: boolean}` | play, match | Host only: closes the room to, or reopens it for, new matchmaking placements (§6.5). |
|
|
187
|
+
| `__chat` | `{text: string}` | play, match | A chat line (§12). |
|
|
188
|
+
| `__chat:history:request` | `{}` | play, match | Asks for the chat replay (§12). |
|
|
189
|
+
| `__voice:ice:request` | `{}` | play, match | Asks for TURN/STUN credentials (§13). |
|
|
190
|
+
| `__voice:join` / `__voice:leave` | `{}` | play, match | Enters or leaves the voice roster (§13). |
|
|
191
|
+
| `__voice:signal` | `{to, data}`, data at most 8 KiB | play, match | Directed WebRTC signalling (§13). |
|
|
192
|
+
| `match:eliminated` / `match:finish` | `{}` | match | Self-report from an active seat (§9.3). |
|
|
193
|
+
| `match:score` | `{delta?: number}` | match | Self-report; delta clamped to ±1000, default 1. |
|
|
194
|
+
| `__pose` | `{x?, y?, z?, yaw?, vx?, vy?, vz?, anim?, t}` | play, match | Writes the sender's typed pose and `poseT = t`; sets `posed` (§6.6). |
|
|
195
|
+
| `__pose:snap` | as `__pose` | play, match | Writes the pose and increments `epoch` in the same patch (a teleport or respawn). |
|
|
196
|
+
|
|
197
|
+
### 5.3 Relay → client
|
|
198
|
+
|
|
199
|
+
| Type | Payload | To |
|
|
200
|
+
|---|---|---|
|
|
201
|
+
| `__object:result` | `ObjectControlResult` (§7.2) | The requester |
|
|
202
|
+
| `__input` | `{from, d}` | The host |
|
|
203
|
+
| `__room:open` | `{open}` | Everyone |
|
|
204
|
+
| `__chat` | `{id, from, name, text, at}` | Everyone, including the sender |
|
|
205
|
+
| `__chat:history` | `{messages: ChatMessage[]}` | The requester |
|
|
206
|
+
| `__voice:ice` | `{iceServers: [{urls, username?, credential?}], ttl}` | The requester |
|
|
207
|
+
| `__voice:signal` | `{from, data}` | The addressed peer |
|
|
208
|
+
| `__voice:rejected` | `{reason: 'full', limit: 6}` | The requester |
|
|
209
|
+
| `rate:drop` | `{dropped}` | The over-budget sender |
|
|
210
|
+
| `server:restart` | `{}` | Everyone (§14) |
|
|
211
|
+
| `match:start` | `{roundId}` | Everyone in a match room |
|
|
212
|
+
| `match:ended` | `{winnerId, losers: string[], draw, scores}` | Everyone in a match room |
|
|
213
|
+
| `match:requeue` | `{reason?}` | Players who must leave and search again (§9.2) |
|
|
214
|
+
| `queue` | `{position, size}` | Searchers (queue room) |
|
|
215
|
+
| `seat` | `{reservation}` | A matched searcher (queue room) |
|
|
216
|
+
|
|
217
|
+
### 5.4 Game events
|
|
218
|
+
|
|
219
|
+
Any client message whose type is a string of 1 to 128 characters and is not reserved is a **game event**. The relay re-broadcasts it, with the same type and payload, to every other client in the room. The sender does not receive its own event, and no sender id is added (a game that needs one puts it in the payload). Game events ride the `bulk` lane and are capped at 16 KiB of encoded payload. They are not stored, and a client MUST NOT queue them while disconnected.
|
|
220
|
+
|
|
221
|
+
These types are reserved, and a client MUST NOT send them as game events:
|
|
222
|
+
- any type starting with `__`, `match:`, `server:` or `rate:`;
|
|
223
|
+
- the exact names `state`, `shared`, `claim`, `release`, `destroy`, `obj`, `queue` and `seat`.
|
|
224
|
+
|
|
225
|
+
The relay registers its handlers through the Colyseus 0.18 declarative `messages` map, whose `_` key is the fallback for unhandled types. That fallback relays game events, and it drops and counts reserved types it has no handler for, numeric types and over-long types. Nothing is ever kicked for an unknown type.
|
|
226
|
+
|
|
227
|
+
## 6. Players, shared state and the host
|
|
228
|
+
|
|
229
|
+
### 6.1 Player state
|
|
230
|
+
|
|
231
|
+
A client publishes its own state with `state`: the JSON text of an object, replaced wholesale, on a fixed tick of about 10–20 Hz, never every render frame. The object MUST contain `__t`, the sender's clock in milliseconds:
|
|
232
|
+
- a monotonic, non-decreasing clock with an arbitrary origin, such as `performance.now()`, that never reads exactly `0` (an object stamp of `0` is reserved, §7.3; start a clock that begins at zero at 1 instead);
|
|
233
|
+
- fractions allowed;
|
|
234
|
+
- the same clock for every message the client sends.
|
|
235
|
+
|
|
236
|
+
The relay stores the text as is. Receivers MUST remove `__t` before handing state to game code, and SHOULD warn a game that tries to use the key itself. For a deliberate discontinuity (respawn, teleport, mode change), the client sends `__state:snap` instead, and receivers drop that player's interpolation history (§10).
|
|
237
|
+
|
|
238
|
+
A client renders its **own** player from its last local publish, never from the relay's echo.
|
|
239
|
+
|
|
240
|
+
### 6.2 Shared map
|
|
241
|
+
|
|
242
|
+
`shared` holds room-wide game facts as JSON text. Any client may write any non-`__` key; by convention only the host writes agreed facts. Writes are last-writer-wins at the relay. There is no delete: write `null` instead.
|
|
243
|
+
|
|
244
|
+
### 6.3 Host election and failover
|
|
245
|
+
|
|
246
|
+
`RoomState.host` is the session id of the one client that runs host-authoritative simulation (NPCs, contested physics, round logic), or `''`.
|
|
247
|
+
|
|
248
|
+
**Election.** The host is the earliest-joined **active** seat (connected, not in grace, not excluded), with signed-in players before guests. A guest hosts only while no signed-in player is active. The election re-runs on every join, final leave and failover.
|
|
249
|
+
|
|
250
|
+
**Stickiness.** An active incumbent keeps the role, with one exception: a signed-in player displaces a guest host. A host demoted by failover does not take the role back when it returns.
|
|
251
|
+
|
|
252
|
+
**Failover.** When the host's socket drops, it stays host for `HOST_FAILOVER_GRACE_MS` (1.5 s). If it has not reconnected by then:
|
|
253
|
+
- the relay elects a new host from the active seats;
|
|
254
|
+
- objects the old host owned move to the new host (`owner` changes, `seq` +1, `t = 0`);
|
|
255
|
+
- if no active seat remains, `host` becomes `''`, and a returning seat restores it.
|
|
256
|
+
|
|
257
|
+
**Client duty.** A client MUST run host simulation only while `host` equals its own session id and its post-reconnect sequence (§8) has completed. It MUST stop the moment either stops being true. Host ticks SHOULD come from a wall-clock timer, not the render loop: a backgrounded tab's frame loop stops, and that freezes the whole room. The tick's dt SHOULD be clamped to 250 ms.
|
|
258
|
+
|
|
259
|
+
### 6.4 Inputs to the host
|
|
260
|
+
|
|
261
|
+
`__input` is the channel for host-authoritative patterns. Any client sends an input, and the relay delivers it **only** to the current host, as `__input {from, d}`. With no host, the input is dropped. Inputs are not queued while disconnected.
|
|
262
|
+
|
|
263
|
+
### 6.5 Closing the room to matchmaking
|
|
264
|
+
|
|
265
|
+
The host sends `__room:open {open: false}` to stop the relay placing new players into this room, and `{open: true}` to reopen it. The relay ignores the message from anyone but the host, and broadcasts `__room:open {open}` to every client. Seated players and reconnects are unaffected.
|
|
266
|
+
|
|
267
|
+
### 6.6 Typed pose (optional)
|
|
268
|
+
|
|
269
|
+
A player MAY publish its avatar pose as typed `PlayerState` fields instead of keys inside `data`. Typed fields cost less on the wire (per-field float32 deltas instead of re-sending the whole JSON text) and let every engine's schema decoder hand the pose to its own interpolation without parsing JSON. It is optional at every level:
|
|
270
|
+
- the relay always accepts it;
|
|
271
|
+
- an engine SDK MAY not offer it;
|
|
272
|
+
- a game opts in per game, for all of its clients. A game whose clients ignore `posed` MUST NOT opt in, because they would not see the pose move.
|
|
273
|
+
|
|
274
|
+
It is an experiment (§15): an SDK keeps it **off by default** until it beats the JSON pose on the A/B gate.
|
|
275
|
+
|
|
276
|
+
**Wire.** The sender publishes `__pose {x?, y?, z?, yaw?, vx?, vy?, vz?, anim?, t}` (`PosePublish` in `protocol.ts`) on the `pose` lane. `__pose:snap` is the same message plus `epoch` +1, for a deliberate discontinuity. The relay validates the whole payload and drops it if any rule fails:
|
|
277
|
+
- `t` is the sender's clock in ms, from the same clock as `__t` (§6.1): a finite, nonzero number;
|
|
278
|
+
- each numeric key that is present is a finite number with |v| ≤ 1e9 (`MAX_POSE_ABS`);
|
|
279
|
+
- `anim`, when present, is a string of at most 64 characters (`MAX_ANIM_CHARS`);
|
|
280
|
+
- unknown keys are ignored.
|
|
281
|
+
|
|
282
|
+
On acceptance the relay writes, in one patch: each present numeric key rounded to float32 (`Math.fround`, so an unchanged float32 never re-sends), `anim` if present, `poseT = t` and `posed = true`. An absent key keeps its stored value, so a 2D game never needs to send `z`. A message from a seat whose state the relay ignores (a requeued player, §9.2) is dropped, as `state` is.
|
|
283
|
+
|
|
284
|
+
**Sender rules.**
|
|
285
|
+
- Publish the pose on the fixed tick that `state` uses (§6.1), at most 30 Hz.
|
|
286
|
+
- The `POSE_KEYS` (`x y z yaw vx vy vz anim`) go only into `__pose`. A posed player MUST NOT also put them in `data`. Everything else stays in `data` via `state`, still stamped with `__t`, and SHOULD be sent only when it changes, since receivers snap it (§10.1).
|
|
287
|
+
- Velocity is what the sender itself integrates, in world units per second. An SDK SHOULD derive it from successive poses (`Δposition / Δt`) when the game does not supply it, because receivers extrapolate with it.
|
|
288
|
+
- Position and velocity are plain float32, never quantized: world sizes vary by orders of magnitude.
|
|
289
|
+
- For a teleport or respawn, send `__pose:snap`. `epoch` is shared with `__state:snap`, and either one discards both of that player's streams.
|
|
290
|
+
- `posed` never returns to `false` for the seat. A player that stops publishing a pose leaves it frozen.
|
|
291
|
+
- Repeating the same `anim` sends nothing. To retrigger a one-shot animation, use a distinct name or a game event.
|
|
292
|
+
|
|
293
|
+
**Receiver rules.**
|
|
294
|
+
- Read the pose in the instance-level change callback, after the whole patch is applied, never field by field. A pose sample is a change in which `poseT` changed and is nonzero.
|
|
295
|
+
- Present a posed player to game code as its parsed `data` with the pose keys merged over it (the pose wins). `state.x` then reads the same whether the game uses typed pose or a JSON key, so a game's code does not change when it opts in.
|
|
296
|
+
- Interpolate per §10.1. Render your own player from your last local publish, as always.
|
|
297
|
+
|
|
298
|
+
## 7. Objects
|
|
299
|
+
|
|
300
|
+
### 7.1 Model
|
|
301
|
+
|
|
302
|
+
An object is shared state that exactly one session may write at a time: its owner.
|
|
303
|
+
- An object comes into existence through an accepted `claim` on a new id and is removed only by `destroy`. A room holds at most 128 objects.
|
|
304
|
+
- The owner simulates the object and publishes it with `obj`. Everyone else renders it interpolated on the owner's clock (§10).
|
|
305
|
+
- When the owner's seat is finally gone, its objects move to the host (`seq` +1, `t = 0`), or become unowned if the room has no host.
|
|
306
|
+
|
|
307
|
+
### 7.2 Control requests: claim, release, destroy
|
|
308
|
+
|
|
309
|
+
Every control request carries a `requestId`: a string of 1 to 96 characters that is unique per session. The relay answers each request it can attribute (valid `requestId`) with exactly one `__object:result`:
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
{ requestId, operation: 'claim' | 'release' | 'remove', id, accepted: boolean,
|
|
313
|
+
owner?: string, sequence: number, reason, retryAfterMs?: number }
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`sequence` is the object's `seq` after the operation (0 if the object does not exist), and `owner` is the owner after the operation. A request without a valid `requestId` is dropped unanswered.
|
|
317
|
+
|
|
318
|
+
The relay evaluates a request in this order. The first rule that applies decides it:
|
|
319
|
+
|
|
320
|
+
1. **Over budget.**
|
|
321
|
+
- If a result for this `requestId` is cached, the request is dropped unanswered, so a replay over budget never amplifies.
|
|
322
|
+
- Otherwise the answer is `rate-limited` with `retryAfterMs: 100`. This result is **not** cached.
|
|
323
|
+
2. **Replay.** If a result for (session, `requestId`) is cached, that result is sent again verbatim and nothing is mutated. The cache holds up to 128 results per session for 60 s, and it survives reconnection (§8).
|
|
324
|
+
3. **Invalid.** An `id` that is not a string of 1 to 128 characters, or an `authority` other than `'host'`, gets `invalid`.
|
|
325
|
+
4. **Claim only.**
|
|
326
|
+
- `authority: 'host'` from a session that is not the host gets `not-host`.
|
|
327
|
+
- A new id when the room already holds 128 objects gets `capacity`.
|
|
328
|
+
- A claim by the current owner is `accepted` with reason `already-owner`. Nothing changes: no `seq` bump and no hold refresh.
|
|
329
|
+
- **Minimum hold.** Suppose the current owner is active (connected, not in grace) and took the object less than `MIN_OWNERSHIP_HOLD_MS` (300 ms) ago. An ordinary claim is then rejected with `held` and `retryAfterMs` set to the remaining hold. A host-authority claim displaces the owner anyway and is accepted with reason `host-authority`. An owner inside reconnection grace never holds.
|
|
330
|
+
- Otherwise the claim is accepted: `owner` = the sender, `seq` +1, `t = 0`, and the hold clock starts.
|
|
331
|
+
5. **Release.** The sender must be the owner, otherwise the result is `missing` (no owner) or `not-owner`. On acceptance `owner = ''`, `seq` +1, `t = 0`, and `d` is kept.
|
|
332
|
+
6. **Destroy.** The sender must be the owner, otherwise `missing` or `not-owner`. On acceptance the object is removed from `objects`. `sequence` reports the last `seq`.
|
|
333
|
+
|
|
334
|
+
Clients:
|
|
335
|
+
- MUST keep a request pending until its result arrives or a client timeout expires (default 5 s, never past 60 s).
|
|
336
|
+
- MUST resend every pending request **with the same `requestId`** after a reconnect (§8). The cache makes the resend idempotent; a `destroy` whose object is already gone still gets its original answer.
|
|
337
|
+
- MAY act optimistically on a claim, rendering themselves as owner before the result arrives. They MUST then revert on a rejection.
|
|
338
|
+
- A fire-and-forget API (the TS SDK's `objects.claim`) still sends a `requestId` and simply ignores the result after reverting.
|
|
339
|
+
|
|
340
|
+
### 7.3 Publishing object state
|
|
341
|
+
|
|
342
|
+
`obj {id, d, t}` comes from the owner. `d` is a JSON **object** (a msgpack map, not text) and `t` is the sender's clock in ms, from the same clock as `__t`. The relay writes `d` as JSON text and `t` as is, in one patch.
|
|
343
|
+
|
|
344
|
+
A message from a non-owner, or with a missing, non-finite or zero `t` (`0` is reserved for "not stamped by the current owner", rule 4 below), a non-object `d`, or a `d` larger than 64 KiB once encoded, is dropped. `__object:snap` is the same message, plus `epoch` +1.
|
|
345
|
+
|
|
346
|
+
Because the relay zeroes `t` on every owner change, a nonzero `t` is always on the **current** owner's clock. When a client decodes an object change, it MUST apply these rules in order:
|
|
347
|
+
|
|
348
|
+
1. If `seq` changed, ownership moved: run the soft handoff of §10 before anything else.
|
|
349
|
+
2. If `epoch` changed, it is a hard reset: discard history and place the object at `d`.
|
|
350
|
+
3. If `t` changed and is nonzero, `(t, d)` is a sample from the current owner. Feed it to interpolation, unless the client is the owner, which renders its own last local publish.
|
|
351
|
+
4. If `t` is 0, `d` was not stamped by the current owner. Render it as the resting pose, but never feed it to a clock estimator.
|
|
352
|
+
|
|
353
|
+
## 8. Reconnection
|
|
354
|
+
|
|
355
|
+
**On the relay.** When a seat's socket closes without consent and without a kick, the relay holds the seat:
|
|
356
|
+
- The grace is 5 s after a 1001 close (a reload or navigation; long enough to rejoin and supersede) and 30 s after any other close.
|
|
357
|
+
- During the grace, `PlayerState.connected` is `false`. The session is excluded from host election and authority, and if it was host, failover starts (§6.3).
|
|
358
|
+
- Its objects stay owned but can be claimed without a hold.
|
|
359
|
+
- A reconnect inside the grace restores the same session id, seat, objects and control-result cache, and nothing is re-elected.
|
|
360
|
+
- If the grace expires or is superseded (§3.6), the seat is torn down: its objects move to the host and the match is forfeited.
|
|
361
|
+
- The grace covers a **joined** socket only. After a reconnect, the new socket is not joined again until the client's SDK acknowledges the relay's `JOIN_ROOM` (about one round trip). Colyseus 0.18 refuses a reconnection grace to a socket that has not acknowledged, so a socket that closes inside that window has its seat torn down at once, exactly as if the grace had expired, and the SDK's next retry is refused. This is a known limit of the Colyseus core, not a relay choice.
|
|
362
|
+
- Kicks get no grace: a revocation (4401), a superseded seat (4409), a match boot, a flood or undecodable-frame close (4002, §1).
|
|
363
|
+
|
|
364
|
+
**On the client.** A client uses its Colyseus SDK's in-place auto-reconnect, which is the only reconnect path. It MUST configure `room.reconnection` on every play and match room with these settings (`ClientReconnection` in `protocol.ts`):
|
|
365
|
+
|
|
366
|
+
| Setting | Value | Why |
|
|
367
|
+
|---|---|---|
|
|
368
|
+
| `enabled` | true | One reconnect owner. |
|
|
369
|
+
| `minUptime` | 0 | A drop in the first seconds is healed like any other. |
|
|
370
|
+
| `delay` / `minDelay` / `maxDelay` | 125 / 100 / 4000 ms | Exponential backoff… |
|
|
371
|
+
| `maxRetries` | 9 | …whose last attempt (about 23.75 s) lands inside the 30 s grace. |
|
|
372
|
+
| `maxEnqueuedMessages` | 0 | A stale pose or event must never replay after the reconnect. |
|
|
373
|
+
|
|
374
|
+
A client MUST NOT send on a socket that is not open. It drops the message instead. Player and object state self-heals on the next publish, and lost game events and inputs SHOULD be counted and reported to the developer.
|
|
375
|
+
|
|
376
|
+
The client handles reconnection like this:
|
|
377
|
+
|
|
378
|
+
- **On drop** (`onDrop(code)`; codes 1001, 1005, 1006 and 4010):
|
|
379
|
+
- mark the session disconnected;
|
|
380
|
+
- stop host simulation;
|
|
381
|
+
- emit `reconnecting {attempt, code}`.
|
|
382
|
+
- **On reconnect** (`onReconnect`), run the **post-reconnect sequence** in this order:
|
|
383
|
+
1. **Wait for the first full-state decode** after the reconnect (`onStateChange`), at most 2 s. `onReconnect` fires before the snapshot is applied. Until it lands, `host` and every `owner` are the pre-drop values, and a demoted ex-host would briefly resume its simulation.
|
|
384
|
+
2. If the outage lasted more than 1 s, discard every stream's interpolation history (one deliberate rebase).
|
|
385
|
+
3. Reconcile local mirrors with the decoded state: objects, voice members and players that were removed while offline. A Colyseus 0.18 SDK's resync fires those removals itself; a client whose SDK does not MUST prune them.
|
|
386
|
+
4. Resend every pending control request with its original `requestId`. Re-request chat history (§12).
|
|
387
|
+
5. Mark the session connected. Resume host simulation if `host` is now this session.
|
|
388
|
+
6. Emit `reconnected`.
|
|
389
|
+
|
|
390
|
+
If the room drops again during this sequence, the client abandons it, and the new drop owns the lifecycle.
|
|
391
|
+
- **On leave** (`onLeave(code)`): the session is over. This covers 4003 after the retries run out, and every terminal code in §11. The client settles pending requests as `disconnected` and emits `disconnect(code)`. Playing again means a fresh join with a fresh token.
|
|
392
|
+
|
|
393
|
+
The **queue** room holds no seat. A client SHOULD disable auto-reconnect on it and search again with backoff when it drops (§9.2).
|
|
394
|
+
|
|
395
|
+
## 9. Matchmaking
|
|
396
|
+
|
|
397
|
+
### 9.1 Presets
|
|
398
|
+
|
|
399
|
+
A game's matchmaking config is server-owned: a preset, a win condition and numeric knobs, set on the project and never sent by a client.
|
|
400
|
+
|
|
401
|
+
| Preset | Seats | Rounds | Notes |
|
|
402
|
+
|---|---|---|---|
|
|
403
|
+
| `duel` | 2 | Countdown → playing → ended, winner stays | `championId`/`challengerId` in the match state. |
|
|
404
|
+
| `arena` | min to max | Free-for-all, winner stays | |
|
|
405
|
+
| `teams` | `playersPerTeam × teams` | Team rounds, winner team stays | Teams are assigned at round start. |
|
|
406
|
+
| `private` | up to max | Rounds, nobody evicted | Invite-code lobbies (`create`/`joinById`). |
|
|
407
|
+
| `open` | up to `maxPlayers` | None: `waiting` until `minPlayers`, then `playing` for good | No scores, win condition or eviction. `fill: 1` refills free seats; `fill: 0` locks the room once it is full. Optional `teams: N` (§9.4). |
|
|
408
|
+
|
|
409
|
+
### 9.2 Queue and seat reservation
|
|
410
|
+
|
|
411
|
+
1. **Search.** `joinOrCreate('queue', options)`. While waiting, the client receives `queue {position, size}` about once a second.
|
|
412
|
+
2. **Seat.** `seat {reservation}` arrives once per search. The client MUST stop handling the queue room, leave it with consent, and call `consumeSeatReservation(reservation)` within 15 s. That puts it in a match room as a player. If the consume fails, it searches again with a fresh token.
|
|
413
|
+
3. **Queue drop.** If the queue socket closes for any other reason, the client searches again with backoff. Reasons include a relay restart, a network drop, and the relay converging duplicate queues with a 4000 close.
|
|
414
|
+
4. **Requeue.** In a match room, `match:requeue` means the client lost a winner-stays round or raced into a full room. It MUST leave the match room with consent and search again within 5 s. If it does not, it is closed with 4000. Until it leaves, its state, objects and host eligibility are ignored.
|
|
415
|
+
5. **Private lobbies.** `create('match', options)` creates an unlisted lobby whose room id is the invite code; friends `joinById(code, options)`. A private lobby never requeues anyone.
|
|
416
|
+
|
|
417
|
+
### 9.3 Match state and reports
|
|
418
|
+
|
|
419
|
+
`RoomState.match` is JSON text that the relay rewrites on every transition:
|
|
420
|
+
|
|
421
|
+
```
|
|
422
|
+
{ v, phase: 'waiting'|'countdown'|'playing'|'ended', players: string[], connectedPlayers: string[],
|
|
423
|
+
teams?: { "team0": string[], ... }, scores: { [competitorId]: number }, roundId: number,
|
|
424
|
+
winnerId: string|null, message: string, startsAt: number|null, endsBy: number|null,
|
|
425
|
+
cfg: { ...numeric knobs, winCondition: { type: string, ... } }, ...preset-specific fields }
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
- `players` holds the active seats' session ids; `connectedPlayers` is the subset whose socket is live.
|
|
429
|
+
- In team presets, `scores` and `winnerId` are keyed by team id.
|
|
430
|
+
- `startsAt` and `endsBy` are relay wall-clock deadlines in epoch ms. A client SHOULD compare them on the server clock once it is synced (§16.3), and only otherwise against its own `Date.now()`.
|
|
431
|
+
- Clients MUST ignore unknown fields.
|
|
432
|
+
|
|
433
|
+
Outcomes are self-reported by active seats and judged by the preset: `match:eliminated` (lastStanding), `match:score {delta}` (firstToScore / highScoreInTime), `match:finish` (firstToFinish). The relay announces `match:start {roundId}` and `match:ended {winnerId, losers, draw, scores}`.
|
|
434
|
+
|
|
435
|
+
### 9.4 Teams and `PlayerState.team`
|
|
436
|
+
|
|
437
|
+
`team` is −1 everywhere except in these two cases:
|
|
438
|
+
- **`teams` preset.** At round start the preset splits the seated players into balanced teams `team0..team<N-1>`, and each player's `team` is the index of the team that `match.teams` lists them in.
|
|
439
|
+
- **`open` preset with `teams: N`** (N from 2 to `min(8, maxPlayers)`; any other value means no teams):
|
|
440
|
+
- When a player is seated, the relay assigns them the team they had earlier in this room, matched by `uid`, if they had one. Otherwise it picks the team with the fewest seated members, lowest index on a tie.
|
|
441
|
+
- It never moves a seated player.
|
|
442
|
+
- It remembers uid → team for the life of the room, so a reload or reconnect keeps the team.
|
|
443
|
+
- `match.teams` lists the current members under `team<i>`.
|
|
444
|
+
|
|
445
|
+
A socket raced into a full room is not seated and keeps −1.
|
|
446
|
+
|
|
447
|
+
## 10. Interpolation contract
|
|
448
|
+
|
|
449
|
+
Remote players and remote-owned objects are rendered slightly in the past and interpolated between snapshots **on the sender's clock**. Using the sender's clock rather than arrival time is what makes network jitter invisible. A client that reproduces the following gets the TypeScript SDK's feel; the numbers are `Interpolation` in `protocol.ts`.
|
|
450
|
+
|
|
451
|
+
A **stream** is one remote player (keyed by session id) or one object (keyed by object id). A **sample** is `(s, state)`, where `s` is `__t` for a player and `t` for an object. A posed player (§6.6) follows §10.1 instead:
|
|
452
|
+
- A player's state change with no `__t`, or a `data` that did not change (only `connected` flipped), is not a sample.
|
|
453
|
+
- For objects, see the rules in §7.3.
|
|
454
|
+
|
|
455
|
+
For each stream:
|
|
456
|
+
|
|
457
|
+
1. **Clock offset, per sender.** The offset belongs to the SENDER (the session whose clock stamped the sample: a player for its own stream, an object's owner for the object), not to the stream, so all of one machine's streams share it and a new stream starts on its sender's converged estimate. On each sample arriving at local time `a`, push `a − s` into that sender's window. The window keeps at least the last 80 samples **and** at least the last 5000 ms of them, whichever reaches further back (a hard cap of 4096 bounds memory). `offset = min(window)`; the fastest sample is the best estimate. A window bounded only by count would shrink in time as a sender adds streams: a host publishing its player and ten objects at 20 Hz fills 80 samples in about 0.36 s. Each stream also keeps its own last 80 values of `a − s`, for the jitter term below.
|
|
458
|
+
2. **Render delay.**
|
|
459
|
+
- Track the publish gaps `s − s_prev`, ignoring any gap of 5 ms or less, or of 2000 ms or more. Keep the last 30.
|
|
460
|
+
- `target = clamp(max(1.5 × median(gaps), 1.25 × (p90(stream values) − offset)), 90, 300)`, where the stream values are that stream's own last 80 `a − s`. The jitter term applies only once the stream holds 8 of them.
|
|
461
|
+
- Start at 100 ms. On each sample, move `delay += (target − delay) × 0.1`.
|
|
462
|
+
3. **Buffer.** Append the sample. Keep 1500 ms of history, never fewer than 2 samples. If the oldest sample is more than 2000 ms **ahead** of the newest, it came from a foreign clock: drop it.
|
|
463
|
+
4. **Sample at `r = now − offset − delay`.**
|
|
464
|
+
- Between two snapshots, interpolate. Finite numbers lerp. A 4-element array of finite numbers is a quaternion `[x,y,z,w]` and slerps along the shortest arc. Every other value (strings, booleans, other arrays, nested objects) keeps the **older** snapshot's value, so discrete changes land in step with the rendered motion.
|
|
465
|
+
- Past the newest snapshot, extrapolate along the last segment: position up to 250 ms beyond it, quaternions up to 150 ms, and discrete fields take the newest value.
|
|
466
|
+
- Before the oldest snapshot, use the oldest.
|
|
467
|
+
5. **Output ease.** `out = prev + (sampled − prev) × (1 − e^(−dt/30))` for numbers, and slerp with τ = 70 ms for quaternions, with dt clamped to [0, 250] ms. Corrections glide instead of snapping.
|
|
468
|
+
6. **Discontinuities.**
|
|
469
|
+
- An `epoch` change drops all of that stream's history and places it at the new state.
|
|
470
|
+
- An ownership change (a `seq` change) is a **soft handoff**:
|
|
471
|
+
- drop the stream's buffer, gaps, jitter values and delay, because the next samples come from a different clock (the new owner's samples map through the new owner's own sender offset; the departing owner keeps its offset);
|
|
472
|
+
- keep the last eased output pose;
|
|
473
|
+
- when the new owner's first sample arrives, seed one bridge sample holding that pose, stamped `s − delay`.
|
|
474
|
+
|
|
475
|
+
The render then glides from where the object is on screen, and two machines' clocks never mix.
|
|
476
|
+
7. **Yourself.** Your own player and the objects you own are rendered from your last local publish, never delayed and never from the relay's echo. Game logic (hit tests, pickups, flags) reads the latest raw sample, never the smoothed value.
|
|
477
|
+
|
|
478
|
+
An engine MAY substitute its SDK's own remote smoothing, such as Colyseus `Predict` for remote entities only. It MUST still honour the clock-domain rules: never feed an unstamped or `t = 0` sample, reset on an ownership change, and reseed on an epoch change. Feel parity is judged by the conformance fixtures (§15), not by this prose.
|
|
479
|
+
|
|
480
|
+
### 10.1 Posed players
|
|
481
|
+
|
|
482
|
+
For a player whose `posed` is `true` (§6.6), the pose is the stream and `data` is not interpolated at all:
|
|
483
|
+
|
|
484
|
+
1. **Samples.** `s = poseT`, and the sample is the pose `(x, y, z, yaw, vx, vy, vz, anim)`. Steps 1–3 above (offset, delay, buffer) are unchanged.
|
|
485
|
+
2. **Sample at `r`.**
|
|
486
|
+
- Between two snapshots: `x y z vx vy vz` lerp. `yaw` lerps along the shortest arc: `yaw0 + wrap(yaw1 − yaw0) × k`, where `wrap` maps into (−π, π]. `anim` keeps the **older** snapshot's value and is never interpolated.
|
|
487
|
+
- Past the newest snapshot: dead-reckon the position with the newest snapshot's velocity, `p + v × min(r − s_newest, 250) / 1000`. A sample whose velocity is zero holds its position. `yaw` extrapolates along the last segment for at most 150 ms, and `anim` takes the newest value.
|
|
488
|
+
- Before the oldest snapshot, use the oldest.
|
|
489
|
+
3. **Output ease.** Numbers ease with τ = 30 ms, and `yaw` along the shortest arc with τ = 70 ms (§10 step 5).
|
|
490
|
+
4. **`data` is snapped.** Each `data` change is applied whole and never blended. A receiver SHOULD apply it when `r` passes its `__t`, on the same offset as the pose stream, so discrete changes land in step with the rendered motion. It MAY apply it on arrival. `data` changes are not samples and never feed the clock offset.
|
|
491
|
+
5. **Discontinuities.** An `epoch` change drops the pose history, places the player at the new pose, and applies `data` at once.
|
|
492
|
+
|
|
493
|
+
An engine that uses Colyseus `Predict` for posed players attaches `x y z vx vy vz` as plain lerped fields and `yaw` in its own attach with `angle: true`. It never attaches `anim`, `poseT`, `posed` or `data`.
|
|
494
|
+
|
|
495
|
+
## 11. Close codes
|
|
496
|
+
|
|
497
|
+
| Code | Meaning | Client action |
|
|
498
|
+
|---|---|---|
|
|
499
|
+
| 1001, 1005, 1006 | Going away / no status / abnormal | The SDK reconnects (§8) |
|
|
500
|
+
| 4010 | Colyseus: server going down, may retry | The SDK reconnects (§8) |
|
|
501
|
+
| 1000 | Normal close | Terminal |
|
|
502
|
+
| 4000 | Consented leave. Also: a requeued match player that did not leave, a server drain after `server:restart`, and duplicate-queue convergence | Terminal. A queue client searches again. |
|
|
503
|
+
| 4001 | Server shutdown | Terminal |
|
|
504
|
+
| 4002 | Protocol error, or the 1200 msg/s ceiling | Terminal; fix the client |
|
|
505
|
+
| 4003 | All reconnection attempts failed | Terminal |
|
|
506
|
+
| 4401 | Session revoked | Terminal; do not rejoin with this identity |
|
|
507
|
+
| 4409 | Superseded by a newer session of the same player | Terminal; do not rejoin automatically |
|
|
508
|
+
|
|
509
|
+
"Terminal" means the seat is gone and a reconnect would be refused. A client emits `disconnect(code)` and stops.
|
|
510
|
+
|
|
511
|
+
## 12. Chat
|
|
512
|
+
|
|
513
|
+
- **Send.** A client sends `__chat {text}` and nothing else.
|
|
514
|
+
- **Normalize.** The relay removes C0 control characters, DEL and bidi embedding/override/isolate characters, collapses whitespace, trims, and clamps to 500 code points without splitting surrogate pairs. A line that ends up empty is dropped.
|
|
515
|
+
- **Stamp and broadcast.** The relay stamps `{id, from, name, text, at}`:
|
|
516
|
+
- `id` is monotonic per room;
|
|
517
|
+
- `from` and `name` come from the verified seat;
|
|
518
|
+
- `at` is relay epoch ms.
|
|
519
|
+
|
|
520
|
+
It broadcasts the stamped line to **everyone, including the sender**.
|
|
521
|
+
- **Render.** A client MUST render only what arrives. It MUST NOT also show its own line locally on submit, because the line would then appear twice, and a line the relay dropped would appear delivered.
|
|
522
|
+
- **History.** `__chat:history:request` returns the last 50 lines (at most 8 KiB), oldest first, to the asker only. A client SHOULD request it after every join and reconnect, once its listener is installed. A push from the relay at join could arrive before the listener exists.
|
|
523
|
+
- **Merge.** Lines are merged by `id`, de-duplicated and kept in ascending order. Mute is client-side only.
|
|
524
|
+
|
|
525
|
+
## 13. Voice
|
|
526
|
+
|
|
527
|
+
Voice is mesh WebRTC for at most **6** participants. No audio crosses Genex infrastructure: the relay owns the roster and carries the signalling.
|
|
528
|
+
|
|
529
|
+
1. **Credentials.** `__voice:ice:request` returns `__voice:ice {iceServers, ttl}` to the asker. It needs a seat but not voice membership: credentials must be in hand before the first peer connection exists.
|
|
530
|
+
- `ttl` is the credential's remaining life in seconds; `0` means STUN only, with nothing to refresh.
|
|
531
|
+
- A client SHOULD refresh at 80% of `ttl`, and no sooner than 60 s.
|
|
532
|
+
- A game never supplies the TURN credential.
|
|
533
|
+
2. **Join.** After the microphone is granted, the client sends `__voice:join`. The relay adds `voice[sid] = true`, or answers `__voice:rejected {reason: 'full', limit: 6}`. Joining is idempotent. `__voice:leave` removes the entry, and a seat's final leave removes it too.
|
|
534
|
+
3. **Signal.** `__voice:signal {to, data}` is delivered only to `to`, as `{from, data}`. The relay forwards it only while both sender and receiver are in `voice`. `data` is one of these:
|
|
535
|
+
- `{t: 'offer', sdp}`
|
|
536
|
+
- `{t: 'answer', sdp}`
|
|
537
|
+
- `{t: 'ice', c}`
|
|
538
|
+
|
|
539
|
+
Here `sdp` is an RTCSessionDescriptionInit and `c` an RTCIceCandidateInit, both as JSON.
|
|
540
|
+
4. **Glare avoidance.** For each pair, the participant whose session id is smaller (ordinal string comparison) is the only offerer. Both sides read the same roster, so they agree. Candidates that arrive before the remote description MUST be buffered.
|
|
541
|
+
|
|
542
|
+
## 14. Server restart
|
|
543
|
+
|
|
544
|
+
Before a relay deploy, every play and match room broadcasts `server:restart {}`, closes itself to new joins, and closes every socket with 4000 after about 3 s. A game SHOULD flush anything it persists when it receives the message. Afterwards the client connects again with a fresh token and lands on the new relay.
|
|
545
|
+
|
|
546
|
+
## 15. Conformance
|
|
547
|
+
|
|
548
|
+
`packages/netcode-bench` is the conformance suite. It drives clients against a real relay through an impairment proxy, with gates for reconnect, failover, handoff, rate lanes and the feel budgets.
|
|
549
|
+
|
|
550
|
+
Any engine's client joins it through a **driver**: a small program that wraps the client and speaks the stdio line-JSON contract in `packages/netcode-bench/DRIVER.md` (connect, state, shared, claim/release/destroy, events, chat, snapshot, interpolated samples, its clock, clock-skew injection).
|
|
551
|
+
- **Semantic fixtures**, `packages/netcode-bench/conformance/*.json`, pin §3.3, §3.4, §3.6, §4, §5.4, §6.1–6.3, §7.1–7.3, §8, §10, §11 and §12. Each one is a series of commands to N clients plus predicates on what those clients report. They are not byte replays, and timing rules such as the minimum hold and reconnection are written as ordered steps with explicit windows.
|
|
552
|
+
- No fixture covers the version refusal (§2), the rate lanes (§5.1), inputs (§6.4), typed pose (§6.6), matchmaking (§9), voice (§13) or the server clock (§16). Only the bench's TypeScript gates check those.
|
|
553
|
+
- `test/conformance.test.ts` runs every fixture with the TypeScript driver as a subprocess, so the contract itself is tested. It also runs every fixture with each driver named in `GENEX_CONFORMANCE_DRIVERS`, both alone and in **mixed-engine rooms** with every other configured driver. The same run puts each subprocess driver through the feel gates as observer and as sender.
|
|
554
|
+
|
|
555
|
+
A client may claim protocol 3 conformance only by passing every fixture, alone and mixed with the TypeScript driver, plus the feel gates with it as sender and as observer. A fixture or gate skipped for a missing driver capability is not a pass, so the driver must implement clock-skew injection. Where the TypeScript SDK and this document disagree, file the disagreement against this document.
|
|
556
|
+
|
|
557
|
+
**Phase 2 experiment gates.** The server clock (§16) and typed pose (§6.6) are experiments. Neither may regress an existing feel gate (mixed-ping ±5 s skew, background tab, burst stall, handoff, ex-owner handoff), judged on multi-seed percentiles. Each stays behind its flag, off by default, until it passes its own gate. One that loses is reported as rejected, with its numbers.
|
|
558
|
+
- **Clock.** Measured RTT lies within ±15% of the impairment proxy's injected round trip, across seeds. Under jitter, only ordering is checked: a later `sNow` never maps to an earlier local time. Two clients' `serverNow` are recorded at the same instant and their gap is reported. Turning the clock on changes no §10 output.
|
|
559
|
+
- **Pose.** An A/B against the same game publishing its pose as JSON in `data`, comparing bytes per tick at the relay and the receivers' remote jitter. Semantic fixtures: yaw interpolation across ±π takes the short way; `anim` changes only at the older snapshot's boundary; `__pose:snap` resets both streams; an invalid pose (non-finite, oversized, a zero `t`, a wrong-typed key) is dropped and changes nothing.
|
|
560
|
+
|
|
561
|
+
## 16. Server clock (optional)
|
|
562
|
+
|
|
563
|
+
The relay MAY publish its room clock, so that every client gets server time and its own round-trip time. It is a capability, not a requirement. Everything in §§1–15 works without it, and a client MUST work against a relay that does not publish it. The relay's knob for it stays off by default until the §15 clock gate passes.
|
|
564
|
+
|
|
565
|
+
### 16.1 The relay
|
|
566
|
+
|
|
567
|
+
- **Declare a heartbeat input.** The relay declares `HeartbeatInput` (`schema.ts`) with Colyseus 0.18 `defineInput(HeartbeatInput, { bufferMaxSize: 4 })`, with no `seqField`, `sanitize`, `idle`, `tickRate`, `stepMs` or `stepSeconds`.
|
|
568
|
+
- **What Colyseus then sends.** The join handshake gains the `INPUT_REFLECTION` and `INPUT_OPTIONS` sections (the latter advertises only `PATCH_RATE`, 33 ms). Every state frame the room sends carries the TIMED modifier bit and an 8-byte prefix `[u32 sNow][u32 ackSeq]`. That includes the full state at join and reconnect and every patch that carries a change.
|
|
569
|
+
- **Idle ticks.** On a patch tick in which nothing changed, Colyseus would send every client an empty heartbeat patch. The relay MAY omit it for a client whose `ackSeq` did not advance since the last frame it sent that client, because such a frame carries no RTT sample. The Genex relay does: in an idle room a client gets about one frame per consumed heartbeat, and none while it sends none. Clients MUST NOT use TIMED frames, or their arrival cadence, for liveness, stall detection or jitter measurement.
|
|
570
|
+
- `sNow` is the room clock: milliseconds since the room was created (`clock.elapsedTime`), read when the frame is built.
|
|
571
|
+
- `ackSeq` is how many of this socket's heartbeats the relay has consumed.
|
|
572
|
+
- **Consume before every patch.** In `onBeforePatch`, which runs on every patch tick, the relay clears each client's heartbeat buffer (`this.inputs.get(sessionId).clear()`). Every heartbeat that arrived before a patch is therefore acked in that patch. The relay reads no field and forwards nothing. Overflow past 4 also counts as consumed, so a burst still acks.
|
|
573
|
+
- **Clock origin.** The relay writes `RoomState.clockOrigin = clock.currentTime − clock.elapsedTime` once in `onCreate`. Colyseus starts the room clock before `onCreate`, and on the server that clock is `Date.now()`, so this is the exact epoch of room-clock zero.
|
|
574
|
+
- **What the relay MUST NOT do.**
|
|
575
|
+
- Call `setSimulationInterval` or `setFixedTimestep`. Either would advertise `FIXED_TIMESTEP` and a tick rate, telling clients' reconcilers about a simulation that does not exist. It would also delay input by up to a step.
|
|
576
|
+
- Enable rewind (`allowRewindState`), which turns on per-input render and reckon stamps.
|
|
577
|
+
- Treat heartbeats as game input. `__input` stays the immediate, untyped forward to the host (§6.4).
|
|
578
|
+
- **Rate limits.** Heartbeats bypass the rate lanes (§5.1). The 4-deep buffer bounds what a flooding client can cost, and the 1200/s socket ceiling still applies.
|
|
579
|
+
- **Cost.** Upstream, each heartbeat is one input frame with a body of about 2 bytes. Downstream, each state frame grows by 8 bytes. An idle room sends a client a 9-byte heartbeat patch only on a tick where that client's ack advanced (about 10 Hz for a foregrounded client, none for one that sends no heartbeats); a relay that does not omit idle frames sends one per patch tick (about 30 Hz). The §15 gate measures both.
|
|
580
|
+
|
|
581
|
+
**Why `defineInput` and not a ping message.** A Colyseus ack is the input buffer's consumed count, not a simulation tick. Any consume primitive advances it, `clear()` included, and `onBeforePatch` runs on every patch tick whether or not a simulation interval exists. So the heartbeat needs no server step. Each official SDK's room clock (TypeScript `RoomClock`, C# `RoomClock.cs`, native-sdk `room_clock`, the Godot `clock_*` getters) already turns TIMED frames into server time and RTT. A `__clock:ping`/`pong` message pair would make every engine reimplement that estimator.
|
|
582
|
+
|
|
583
|
+
### 16.2 The client
|
|
584
|
+
|
|
585
|
+
- **Detect.** The clock is present when the join handshake carries `INPUT_REFLECTION`. The TypeScript SDK then replaces `room.clock`'s stub with a real clock. Without it, the client MUST NOT send heartbeats and has no `serverNow`.
|
|
586
|
+
- **Decode.** A client of a clock-publishing relay MUST decode TIMED frames. Every official Colyseus 0.18 SDK does; one that cannot is not conformant.
|
|
587
|
+
- **Send heartbeats.**
|
|
588
|
+
- Send `HeartbeatInput` as a reliable input (TypeScript: `room.input().send()`) at 10 Hz (`ClockSync.HEARTBEAT_HZ`), and always within 5–20 Hz while foregrounded. Dither each interval by up to ±15% (`ClockSync.HEARTBEAT_DITHER`): 100 ms is exactly three patch intervals, so a fixed beat phase-locks to the patch tick and every RTT sample carries the same hold, up to one patch interval, for seconds at a time.
|
|
589
|
+
- Every seat sends, including the host and players who never send game input. A client that never sends has no RTT, and its offset stays at its first, one-way-biased sample forever.
|
|
590
|
+
- Drive the heartbeat from a wall-clock timer, never the render loop, from join until leave.
|
|
591
|
+
- Send only on an open socket and never queue a heartbeat (§8).
|
|
592
|
+
- The body MAY be empty. Game data MUST NOT ride in a heartbeat; the relay ignores its `n` field.
|
|
593
|
+
- **Reconnect.** When a reconnect starts, reset the heartbeat count to 0, because the relay allocates a fresh buffer for the reconnected socket. The Colyseus SDKs do this in their drop handler. The room clock itself continues, so the offset estimate stays valid.
|
|
594
|
+
- **Keep §10 on sender clocks.** A client MUST NOT feed `sNow` or `serverNow` into the §10 streams. Remote interpolation stays on sender clocks; interpolating on the server timeline is a separate experiment and is not part of protocol 3.
|
|
595
|
+
|
|
596
|
+
**Derivation.** A client on an official SDK gets this from its room clock. A hand-rolled client MUST match it.
|
|
597
|
+
- On each TIMED frame arriving at local time `a`: if `ackSeq` exceeds the last ack and the send time of heartbeat number `ackSeq` is known, the RTT sample is `a − sendTime[ackSeq]`. Otherwise the frame has no sample.
|
|
598
|
+
- **Offset.** The first frame seeds `offset = sNow − a`, which is biased by the one-way latency. After that, only frames with an RTT sample update it, by an EMA (α = 0.1) toward `sNow + rtt/2 − a`. From the 31st sample on, a sample counts only if its RTT is at most 1.2× the minimum RTT of the last 10 s.
|
|
599
|
+
- `serverNow() = localNow + offset`.
|
|
600
|
+
- **RTT.** `rtt` is the latest sample, and the smoothed RTT is an EMA (α = 0.1) that rejects samples above 4× the current smoothed value.
|
|
601
|
+
- **Render clock.** `renderNow()` is a slew-limited copy (τ = 250 ms; it snaps on a gap above 250 ms), for drawing only.
|
|
602
|
+
|
|
603
|
+
**Bias.** Each RTT sample includes the relay's hold before the next patch: 0 to one patch interval, about 17 ms on average. RTT therefore reads high by up to one patch interval. Treat it as an upper bound. The gated offset relies on near-minimum samples, whose hold is close to zero.
|
|
604
|
+
|
|
605
|
+
**Synced.** A client treats `serverNow` as synced after `ClockSync.WARMUP_ACKS` (30) acked heartbeats, about 3 s at 10 Hz. The one-way bias of the seed decays as 0.9^k over k RTT samples: about 35% of it remains after 10 samples and about 4% after 30 (about 6 ms at 150 ms one way). A client that switched deadlines to the server clock earlier would show a countdown that jumps and then drifts by tens of ms.
|
|
606
|
+
|
|
607
|
+
### 16.3 Server time for deadlines
|
|
608
|
+
|
|
609
|
+
- Any relay epoch stamp maps onto the server clock as `serverMs = epochMs − clockOrigin`. This covers `match.startsAt`, `match.endsBy` and chat `at`.
|
|
610
|
+
- Time remaining until a match deadline is `(deadline − clockOrigin) − serverNow()`. A client SHOULD use this once its clock is synced and `clockOrigin` is nonzero. It is immune to the skew of the player's own wall clock.
|
|
611
|
+
- Before then, or against a relay without a clock, the client falls back to `deadline − Date.now()`.
|
|
612
|
+
- Deadlines use `serverNow()`, never `renderNow()`.
|
|
613
|
+
- The relay keeps writing deadlines in epoch ms. The §9.3 shape does not change.
|
|
614
|
+
|
|
615
|
+
## Appendix: deliberately not in protocol 3
|
|
616
|
+
|
|
617
|
+
- **Server-authoritative simulation, prediction reconciliation and lag compensation.** The relay runs no game code.
|
|
618
|
+
- **A relay fixed timestep, a typed universal input schema, or rewind stamps.** The §16 heartbeat exists only for the clock. A typed input belongs to a specific authoritative kernel, and the relay has none.
|
|
619
|
+
- **Remote interpolation on the server timeline.** §10 stays on sender clocks. The server clock serves deadlines and RTT (§16).
|
|
620
|
+
- **Colyseus `room.request()`.** The Godot binding does not expose it. Control requests carry `requestId` in the payload instead.
|
|
621
|
+
- **Colyseus `validate()` on messages.** A failed validation closes the socket with 4002, which contradicts §1's drop policy.
|
|
622
|
+
- **`SetSchema`, quantized/angle fields, nested collections, `.unreliable()` and `.patchOnly()` in room state.** Native decoder support for them is unverified, or they break late joiners. The optional typed pose (§6.6) is therefore flat float32 and string fields, with yaw wrapped by receivers rather than stored as `t.angle()`.
|
|
623
|
+
- **Typed pose on objects.** Only `PlayerState` carries the optional pose. `ObjectState.d` stays JSON until the player pose has won its A/B.
|
|
624
|
+
- **Typed match state.** `match` stays JSON text until native decoders are shown to handle nested team rosters.
|