@vgai/p2p-colyseus 0.5.2 → 0.5.4

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@vgai/p2p-colyseus",
3
3
  "author": "Volter AI, Inc.",
4
4
  "license": "Apache-2.0",
5
- "version": "0.5.2",
5
+ "version": "0.5.4",
6
6
  "type": "module",
7
7
  "description": "Colyseus-compatible transport for vgai — real Colyseus, loopback, WebRTC, or relay, chosen at runtime.",
8
8
  "homepage": "https://github.com/volter-ai/vgai-engine#readme",
@@ -18,8 +18,6 @@
18
18
  "exports": {
19
19
  ".": "./src/index.ts",
20
20
  "./engine": "./src/engine.ts",
21
- "./schema": "./src/schema.ts",
22
- "./codec": "./src/codec.ts",
23
21
  "./client": "./src/client.ts",
24
22
  "./browser": "./src/browser.ts",
25
23
  "./server": "./src/room.ts",
@@ -35,11 +33,11 @@
35
33
  "wrangler:deploy": "wrangler deploy --config wrangler.toml"
36
34
  },
37
35
  "dependencies": {
36
+ "@colyseus/schema": "^4.0.14",
38
37
  "ws": "^8.19.0"
39
38
  },
40
39
  "devDependencies": {
41
40
  "@colyseus/sdk": "^0.17.34",
42
- "@colyseus/schema": "^4.0.14",
43
41
  "@colyseus/ws-transport": "^0.17.9",
44
42
  "@types/node": "^25.3.0",
45
43
  "@types/ws": "^8.5.13",
@@ -1,7 +1,101 @@
1
1
  export interface P2PAccessTokenClaims {
2
2
  readonly roomName: string;
3
3
  readonly peerId?: string | undefined;
4
+ /**
5
+ * The account this token was minted for. Absent on anonymous/self-hosted
6
+ * tokens (which must still verify — backward compat); present on managed-lane
7
+ * tokens so the relay can meter a free byte allotment per account.
8
+ */
9
+ readonly userId?: string | undefined;
4
10
  readonly expiresAt: number;
11
+ /**
12
+ * Token purpose. Absent/`'relay'` → a spendable relay access token the relay
13
+ * accepts. `'grant'` → a per-game mint GRANT (see {@link mintRelayGrant}):
14
+ * NOT spendable on the relay, only exchangeable at `/relay/session` for a
15
+ * fresh relay token. The relay read path ({@link readP2PAccessToken}) rejects
16
+ * `'grant'` outright, and a grant is signed with a DIFFERENT key besides, so
17
+ * a grant can never be replayed as a relay token.
18
+ */
19
+ readonly kind?: 'relay' | 'grant' | undefined;
20
+ }
21
+
22
+ /**
23
+ * The grant claims baked into a deployed game's public manifest. A grant
24
+ * authorizes ANONYMOUS players of that one game to mint short-lived relay
25
+ * tokens for the deployer's account, scoped to the deployed room — the
26
+ * authorization that lets `/relay/session` (unauthenticated) safely issue a
27
+ * relay token bearing the deployer's `userId`. Abuse is bounded to the
28
+ * deployer's per-account/per-room relay byte budget, exactly as the old baked
29
+ * relay token was — but the grant is refreshable and, unlike a raw relay
30
+ * token, is not directly spendable on the relay.
31
+ */
32
+ export interface RelayGrantClaims {
33
+ readonly kind: 'grant';
34
+ readonly userId: string;
35
+ readonly roomName: string;
36
+ readonly expiresAt: number;
37
+ }
38
+
39
+ /**
40
+ * Domain-separated grant-signing subkey derived from the relay access secret.
41
+ * A grant is signed with THIS key, never the raw secret, so the relay — which
42
+ * only ever holds the raw secret — cannot verify (and therefore cannot accept)
43
+ * a grant as a spendable token. This gives cryptographic key separation
44
+ * between "grant" and "relay token" with a SINGLE provisioned secret; the relay
45
+ * needs no new binding and never learns this subkey.
46
+ */
47
+ export async function deriveRelayGrantKey(secret: string): Promise<string> {
48
+ return sign(secret, 'vgai-relay-grant-v1');
49
+ }
50
+
51
+ /**
52
+ * Mint a long-lived per-game relay-mint GRANT (see {@link RelayGrantClaims}).
53
+ * Signed with {@link deriveRelayGrantKey}, so it is inert against the relay.
54
+ * `vgai deploy` mints one of these (authenticated as the deployer) and bakes it
55
+ * into the served manifest; players exchange it at `/relay/session` for a fresh
56
+ * short-lived relay token at connect time — which is why a deployed game's
57
+ * multiplayer never expires on the token's clock, only on the grant's much
58
+ * longer one.
59
+ */
60
+ export async function mintRelayGrant(
61
+ secret: string,
62
+ opts: { userId: string; roomName: string; ttlMs: number; nowMs: number },
63
+ ): Promise<{ grant: string; expiresAt: number }> {
64
+ const grantKey = await deriveRelayGrantKey(secret);
65
+ const expiresAt = opts.nowMs + opts.ttlMs;
66
+ const grant = await createP2PAccessToken(grantKey, {
67
+ kind: 'grant',
68
+ userId: opts.userId,
69
+ roomName: opts.roomName,
70
+ expiresAt,
71
+ });
72
+ return { grant, expiresAt };
73
+ }
74
+
75
+ /**
76
+ * Verify a grant and return its `{ userId, roomName }` (or null when invalid,
77
+ * wrong-kind, or expired). The exchange path `/relay/session` calls this to
78
+ * decide whether to mint a relay token, and for which account/room.
79
+ */
80
+ export async function readRelayGrant(
81
+ secret: string,
82
+ grant: string,
83
+ now = Date.now(),
84
+ ): Promise<{ userId: string; roomName: string } | null> {
85
+ const grantKey = await deriveRelayGrantKey(secret);
86
+ const [payload, signature, extra] = grant.split('.');
87
+ if (!payload || !signature || extra !== undefined) return null;
88
+ if (!constantTimeEqual(signature, await sign(grantKey, payload))) return null;
89
+ let claims: RelayGrantClaims;
90
+ try {
91
+ claims = JSON.parse(decodeBase64Url(payload)) as RelayGrantClaims;
92
+ } catch {
93
+ return null;
94
+ }
95
+ if (claims.kind !== 'grant') return null;
96
+ if (typeof claims.userId !== 'string' || typeof claims.roomName !== 'string') return null;
97
+ if (!(Number.isFinite(claims.expiresAt) && claims.expiresAt > now)) return null;
98
+ return { userId: claims.userId, roomName: claims.roomName };
5
99
  }
6
100
 
7
101
  export async function createP2PAccessToken(
@@ -13,27 +107,76 @@ export async function createP2PAccessToken(
13
107
  return `${payload}.${signature}`;
14
108
  }
15
109
 
16
- export async function verifyP2PAccessToken(
110
+ /**
111
+ * NEW (2026-08): the shared relay-token mint. A thin wrapper over
112
+ * {@link createP2PAccessToken} that turns a wall-clock issuance moment plus a
113
+ * TTL into the `expiresAt` the token claims carry, so every managed home that
114
+ * issues relay access (the CF-native `@vgai/auth` Worker, and the legacy
115
+ * account-service route in `app.ts` until it is retired) computes expiry one
116
+ * way. Keeps the `createP2PAccessToken` primitive untouched — this only owns
117
+ * the `expiresAt = nowMs + ttlMs` convention and returns it alongside the token
118
+ * so the caller can echo it to the client.
119
+ *
120
+ * `expiresAt` is an epoch-MILLISECONDS instant, not seconds: the read path
121
+ * ({@link readP2PAccessToken}) compares it against `Date.now()` directly, and
122
+ * the account-service route it mirrors (`app.ts` `relayToken`) already mints
123
+ * `Date.now() + ttlMs`. A seconds value would be ~1000x smaller than `now` and
124
+ * so read as permanently expired.
125
+ */
126
+ export async function mintRelayToken(
127
+ secret: string,
128
+ opts: { userId: string; roomName: string; ttlMs: number; nowMs: number },
129
+ ): Promise<{ token: string; expiresAt: number }> {
130
+ const expiresAt = opts.nowMs + opts.ttlMs;
131
+ const token = await createP2PAccessToken(secret, {
132
+ roomName: opts.roomName,
133
+ userId: opts.userId,
134
+ expiresAt,
135
+ });
136
+ return { token, expiresAt };
137
+ }
138
+
139
+ /**
140
+ * Verify a token AND return its decoded claims (or null when invalid). This is
141
+ * the read path the relay uses to recover `userId` for per-account metering;
142
+ * {@link verifyP2PAccessToken} is the boolean shorthand over the same checks.
143
+ */
144
+ export async function readP2PAccessToken(
17
145
  secret: string,
18
146
  token: string,
19
147
  expected: { roomName: string; peerId?: string | undefined },
20
148
  now = Date.now(),
21
- ): Promise<boolean> {
149
+ ): Promise<P2PAccessTokenClaims | null> {
22
150
  const [payload, signature, extra] = token.split('.');
23
- if (!payload || !signature || extra !== undefined) return false;
151
+ if (!payload || !signature || extra !== undefined) return null;
24
152
  const expectedSignature = await sign(secret, payload);
25
- if (!constantTimeEqual(signature, expectedSignature)) return false;
153
+ if (!constantTimeEqual(signature, expectedSignature)) return null;
26
154
 
27
155
  let claims: P2PAccessTokenClaims;
28
156
  try {
29
157
  claims = JSON.parse(decodeBase64Url(payload)) as P2PAccessTokenClaims;
30
158
  } catch {
31
- return false;
159
+ return null;
32
160
  }
33
161
 
34
- if (claims.roomName !== expected.roomName) return false;
35
- if (expected.peerId && claims.peerId && claims.peerId !== expected.peerId) return false;
36
- return Number.isFinite(claims.expiresAt) && claims.expiresAt > now;
162
+ // A GRANT is never spendable on the relay — it is only exchangeable at
163
+ // /relay/session. Reject it here too (belt-and-suspenders: a grant is also
164
+ // signed with a different key, so it would already fail the signature check
165
+ // above unless the grant key were misconfigured to equal the raw secret).
166
+ if (claims.kind === 'grant') return null;
167
+ if (claims.roomName !== expected.roomName) return null;
168
+ if (expected.peerId && claims.peerId && claims.peerId !== expected.peerId) return null;
169
+ if (!(Number.isFinite(claims.expiresAt) && claims.expiresAt > now)) return null;
170
+ return claims;
171
+ }
172
+
173
+ export async function verifyP2PAccessToken(
174
+ secret: string,
175
+ token: string,
176
+ expected: { roomName: string; peerId?: string | undefined },
177
+ now = Date.now(),
178
+ ): Promise<boolean> {
179
+ return (await readP2PAccessToken(secret, token, expected, now)) !== null;
37
180
  }
38
181
 
39
182
  async function sign(secret: string, payload: string): Promise<string> {
package/src/browser.ts CHANGED
@@ -1,3 +1,49 @@
1
+ export {
2
+ $changes,
3
+ $childType,
4
+ $decoder,
5
+ $deleteByIndex,
6
+ $encoder,
7
+ $filter,
8
+ $getByIndex,
9
+ $refId,
10
+ $track,
11
+ ArraySchema,
12
+ Callbacks as SchemaCallbacks,
13
+ ChangeTree,
14
+ CollectionSchema,
15
+ Decoder,
16
+ decode,
17
+ decodeKeyValueOperation,
18
+ decodeSchemaOperation,
19
+ defineCustomTypes,
20
+ defineTypes,
21
+ deprecated,
22
+ dumpChanges,
23
+ Encoder,
24
+ encode,
25
+ encodeArray,
26
+ encodeKeyValueOperation,
27
+ encodeSchemaOperation,
28
+ entity,
29
+ getDecoderStateCallbacks,
30
+ getRawChangesCallback,
31
+ MapSchema,
32
+ Metadata,
33
+ OPERATION,
34
+ Reflection,
35
+ ReflectionField,
36
+ ReflectionType,
37
+ registerType,
38
+ Schema,
39
+ SetSchema,
40
+ StateCallbackStrategy,
41
+ StateView,
42
+ schema,
43
+ TypeContext,
44
+ type,
45
+ view,
46
+ } from '@colyseus/schema';
1
47
  export type { P2PAccessTokenClaims } from './access-token';
2
48
  export { createP2PAccessToken, verifyP2PAccessToken } from './access-token';
3
49
  export { Callbacks } from './callbacks';
@@ -19,7 +65,6 @@ export {
19
65
  export { SignalingRelayCoordinator } from './cloudflare/coordinator';
20
66
  export type { RelayLimits } from './cloudflare/limits';
21
67
  export { DEFAULT_RELAY_LIMITS } from './cloudflare/limits';
22
- export { applyStatePatch, clone, createStatePatch, encodeSnapshot } from './codec';
23
68
  export type {
24
69
  NetDiagnostics,
25
70
  RelayProofDiagnostics,
@@ -146,57 +191,11 @@ export {
146
191
  WebSocketClient,
147
192
  WebSocketTransport,
148
193
  } from './platform';
149
- export type { Envelope, Snapshot, StatePatch, StatePatchOperation } from './protocol';
194
+ export type { Envelope } from './protocol';
150
195
  export { isEnvelope, P2P_CLOSE_CODES } from './protocol';
151
196
  export { CloudflareRelayPacketChannel } from './relay';
152
197
  export type { CompatClient } from './room';
153
198
  export { Room } from './room';
154
199
  export type { RoomClass } from './runtime';
155
200
  export { UniversalRoomRuntime } from './runtime';
156
- export {
157
- $changes,
158
- $childType,
159
- $decoder,
160
- $deleteByIndex,
161
- $encoder,
162
- $filter,
163
- $getByIndex,
164
- $refId,
165
- $track,
166
- ArraySchema,
167
- Callbacks as SchemaCallbacks,
168
- ChangeTree,
169
- CollectionSchema,
170
- Decoder,
171
- decode,
172
- decodeKeyValueOperation,
173
- decodeSchemaOperation,
174
- defineCustomTypes,
175
- defineTypes,
176
- deprecated,
177
- dumpChanges,
178
- Encoder,
179
- encode,
180
- encodeArray,
181
- encodeKeyValueOperation,
182
- encodeSchemaOperation,
183
- entity,
184
- getDecoderStateCallbacks,
185
- getRawChangesCallback,
186
- MapSchema,
187
- Metadata,
188
- OPERATION,
189
- Reflection,
190
- ReflectionField,
191
- ReflectionType,
192
- registerType,
193
- Schema,
194
- SetSchema,
195
- StateCallbackStrategy,
196
- StateView,
197
- schema,
198
- TypeContext,
199
- type,
200
- view,
201
- } from './schema';
202
201
  export { createWebRTCDataChannelPacketChannel, WebRTCDataChannelPacketChannel } from './webrtc';
package/src/callbacks.ts CHANGED
@@ -1,7 +1,11 @@
1
- import type { CompatRoom } from './client';
2
-
3
- export const Callbacks = {
4
- get(room: CompatRoom): CompatRoom['replication'] {
5
- return room.replication;
6
- },
7
- };
1
+ /**
2
+ * The client Callbacks API is the REAL `@colyseus/schema` one.
3
+ *
4
+ * `Callbacks.get(room)` returns the legacy string-keyed strategy
5
+ * (`onAdd('players', …)`, `onChange(item, …)`, `bindTo(…)`) and accepts a
6
+ * `{ serializer: { decoder } }` — exactly the shape `CompatRoom` exposes — so
7
+ * an authored game that calls `Callbacks.get(room)` behaves identically on the
8
+ * P2P path and against a real Colyseus server. The v4 `$` proxy is
9
+ * `getStateCallbacks(room)` (see `client.ts`).
10
+ */
11
+ export { Callbacks } from '@colyseus/schema';
package/src/channels.ts CHANGED
@@ -21,4 +21,15 @@ export interface PacketChannel {
21
21
  * against a wire-incompatible peer instead of hanging forever.
22
22
  */
23
23
  onProtocolError?(handler: (reason: string) => void): () => void;
24
+ /**
25
+ * Optional: send this envelope over an UNRELIABLE (unordered, no-retransmit)
26
+ * sub-transport when the channel has one — the delivery `sendUnreliable`
27
+ * asks for. Only the WebRTC channel has a second `RTCDataChannel`
28
+ * (`{ordered: false, maxRetransmits: 0}`) to carry it; ordered transports
29
+ * (loopback, websocket, relay, real Colyseus) have no unreliable mode and
30
+ * omit this, so `CompatRoom.sendUnreliable` correctly degrades to the
31
+ * reliable `send` path on them. The envelope shape is identical to `send`'s
32
+ * — same JSON `Envelope` protocol, different channel.
33
+ */
34
+ sendUnreliable?(envelope: Envelope): void;
24
35
  }
package/src/client.ts CHANGED
@@ -1,14 +1,21 @@
1
+ import {
2
+ Decoder,
3
+ getDecoderStateCallbacks,
4
+ Schema,
5
+ type SchemaCallbackProxy,
6
+ } from '@colyseus/schema';
1
7
  import type { PacketChannel } from './channels';
2
- import { applyStatePatch } from './codec';
3
8
  import type { P2PColyseusMode } from './engine';
4
9
  import { createSignal } from './events';
5
10
  import { createLoopbackPair } from './loopback';
6
11
  import { type Envelope, P2P_CLOSE_CODES } from './protocol';
7
- import { ClientReplication } from './replication';
12
+ import { getRegisteredRoom, registeredRoomNames, registerLoopbackRoom } from './room-registry';
8
13
  import { type RoomClass, UniversalRoomRuntime } from './runtime';
9
14
  import { createWebSocketPacketChannel } from './websocket';
15
+ import { base64ToBytes } from './wire-bytes';
16
+
17
+ export { registerLoopbackRoom } from './room-registry';
10
18
 
11
- const loopbackRooms = new Map<string, RoomClass>();
12
19
  let configuredP2P:
13
20
  | {
14
21
  mode: Extract<P2PColyseusMode, { kind: 'p2p-host' | 'p2p-join' }>;
@@ -19,10 +26,6 @@ let configuredP2P:
19
26
  const hostedP2PRoomIds = new Map<string, string>();
20
27
  const localP2PClientCounts = new Map<string, number>();
21
28
 
22
- export function registerLoopbackRoom(roomName: string, roomClass: RoomClass): void {
23
- loopbackRooms.set(roomName, roomClass);
24
- }
25
-
26
29
  export function configureP2PColyseusClient(options: {
27
30
  mode: Extract<P2PColyseusMode, { kind: 'p2p-host' | 'p2p-join' }>;
28
31
  rooms?: Record<string, RoomClass> | undefined;
@@ -154,7 +157,7 @@ export class Client {
154
157
  async getAvailableRooms(roomName?: string): Promise<AvailableRoom[]> {
155
158
  const roomNames = configuredP2P
156
159
  ? Object.keys(configuredP2P.rooms ?? {})
157
- : [...loopbackRooms.keys()];
160
+ : registeredRoomNames();
158
161
  return roomNames
159
162
  .filter((name) => roomName === undefined || name === roomName)
160
163
  .map((name) => ({
@@ -215,7 +218,7 @@ export class Client {
215
218
  if (!this.url.startsWith('loopback://')) {
216
219
  throw new Error(`Unsupported @vgai/p2p-colyseus client URL: ${this.url}`);
217
220
  }
218
- const roomClass = loopbackRooms.get(roomName);
221
+ const roomClass = getRegisteredRoom(roomName);
219
222
  if (!roomClass) throw new Error(`Loopback room not registered: ${roomName}`);
220
223
 
221
224
  // joinOrCreate semantics: the creating client's options reach onCreate.
@@ -277,14 +280,65 @@ function publishP2PMode(mode: string): void {
277
280
 
278
281
  export const ColyseusSDK = Client;
279
282
 
280
- export function getStateCallbacks(room: CompatRoom): ClientReplication {
281
- return room.replication;
283
+ /**
284
+ * The v4 Callbacks proxy over the client's REAL `@colyseus/schema` decoder —
285
+ * identical to `@colyseus/sdk`'s `getStateCallbacks(room)`
286
+ * (`$(state).players.onAdd(...)`, `$(item).listen(...)`, `$(item).onChange`,
287
+ * `$(from).bindTo(...)`). The legacy string-keyed API is `Callbacks.get(room)`,
288
+ * which the real `@colyseus/schema` `Callbacks` provides directly off
289
+ * `room.serializer.decoder`.
290
+ */
291
+ export function getStateCallbacks(room: CompatRoom): SchemaCallbackProxy<Schema> {
292
+ if (!room.decoder) {
293
+ throw new Error(
294
+ `getStateCallbacks: room "${room.name}" has no decoded @colyseus/schema state — ` +
295
+ `register the room class via configureP2PColyseusClient({ rooms }).`,
296
+ );
297
+ }
298
+ return getDecoderStateCallbacks(room.decoder);
282
299
  }
283
300
 
284
301
  export function registerSerializer(): void {}
285
302
 
303
+ /**
304
+ * Build the client-side decoder root from the LOCALLY-REGISTERED room class
305
+ * (shipped to the client via `configureP2PColyseusClient({ rooms })`). No
306
+ * Reflection handshake — a fresh instance of the room's `Schema` state class is
307
+ * all the real `Decoder` needs, and it is compatible with the host `Encoder`
308
+ * because both use the same class through the same `@colyseus/schema`.
309
+ */
310
+ function buildDecoder(roomName: string): Decoder<Schema> | undefined {
311
+ const RoomClass = getRegisteredRoom(roomName);
312
+ if (!RoomClass) return undefined;
313
+ const probe = new RoomClass() as { state?: unknown; onCreate?: (options?: unknown) => void };
314
+ let state = probe.state;
315
+ if (!(state instanceof Schema)) {
316
+ // Rooms that assign state in onCreate rather than a field initializer.
317
+ try {
318
+ probe.onCreate?.();
319
+ } catch {
320
+ /* best-effort probe — the state class is all we need */
321
+ }
322
+ state = probe.state;
323
+ }
324
+ try {
325
+ (probe as { _disposeRuntime?: () => void })._disposeRuntime?.();
326
+ } catch {
327
+ /* the probe never attached a runtime; nothing to clean up */
328
+ }
329
+ if (!(state instanceof Schema)) return undefined;
330
+ return new Decoder(new (state.constructor as new () => Schema)());
331
+ }
332
+
286
333
  export class CompatRoom {
287
- readonly replication = new ClientReplication();
334
+ /**
335
+ * The client's real `@colyseus/schema` decoder — undefined for stateless
336
+ * rooms. Rebuilt from a fresh state root on a FULL snapshot (resync) so
337
+ * host-side removals since the last sync cannot linger as ghost entries.
338
+ */
339
+ decoder: Decoder<Schema> | undefined;
340
+ /** Exposed so the real `Callbacks.get(room)` / `getStateCallbacks(room)` resolve the decoder. */
341
+ readonly serializer: { decoder: Decoder<Schema> | undefined };
288
342
  readonly onLeave = createSignal<(code?: number, reason?: string) => void>();
289
343
  readonly onError = createSignal<(code: number, message?: string) => void>();
290
344
  readonly onReconnect = createSignal<() => void>();
@@ -293,7 +347,7 @@ export class CompatRoom {
293
347
  roomId = '';
294
348
  sessionId = '';
295
349
  reconnectionToken = '';
296
- state: Record<string, unknown> = this.replication.state;
350
+ state: Record<string, unknown> = {};
297
351
  private readonly messages = new Map<string, Set<(payload: unknown) => void>>();
298
352
  private readonly wildcardMessages = new Set<(type: string, payload: unknown) => void>();
299
353
  private readonly pendingPings = new Map<number, (ms: number) => void>();
@@ -303,6 +357,9 @@ export class CompatRoom {
303
357
  readonly name: string,
304
358
  private readonly channel: PacketChannel,
305
359
  ) {
360
+ this.decoder = buildDecoder(name);
361
+ this.serializer = { decoder: this.decoder };
362
+ if (this.decoder) this.state = this.decoder.state as unknown as Record<string, unknown>;
306
363
  this.channel.onEnvelope((envelope) => this.handleEnvelope(envelope));
307
364
  this.channel.onClose((reason) => {
308
365
  this.onDrop.emit(undefined, reason);
@@ -318,11 +375,11 @@ export class CompatRoom {
318
375
  // package's compat wire protocol at all — most concretely, a compat
319
376
  // client pointed at a real Colyseus server (raw `ws://`), which frames
320
377
  // its own binary schema-diff protocol and never sends `join-ok`/
321
- // `join-error`. Without this, that mismatch hangs `join()` forever
322
- // (see docs/decisions/h4-endpoint-interop.md). All three listeners are
323
- // torn down the moment the join settles one way or another, so none of
324
- // them can observe or reject on anything that happens in a normal
325
- // session after a successful join, including its normal eventual close.
378
+ // `join-error`. Without this, that mismatch hangs `join()` forever. All
379
+ // three listeners are torn down the moment the join settles one way or
380
+ // another, so none of them can observe or reject on anything that
381
+ // happens in a normal session after a successful join, including its
382
+ // normal eventual close.
326
383
  const unsubscribers: Array<() => void> = [];
327
384
  const teardown = () => {
328
385
  for (const off of unsubscribers) off();
@@ -336,8 +393,7 @@ export class CompatRoom {
336
393
  this.sessionId = envelope.sessionId;
337
394
  this.reconnectionToken = `${this.roomId}:${this.sessionId}`;
338
395
  this.clock = envelope.clock;
339
- this.replication.applySnapshot(envelope.snapshot.state);
340
- this.state = this.replication.state;
396
+ this.applyEncodedState(envelope.state);
341
397
  resolve();
342
398
  } else if (envelope.kind === 'join-error' && envelope.requestId === requestId) {
343
399
  teardown();
@@ -369,6 +425,15 @@ export class CompatRoom {
369
425
  }
370
426
 
371
427
  sendUnreliable<T = unknown>(type: string, payload?: T): void {
428
+ // Route over the channel's unreliable (unordered, no-retransmit) transport
429
+ // when it has one — only the WebRTC channel does. On ordered transports
430
+ // (loopback, websocket, relay, real Colyseus) there is no unreliable
431
+ // channel, and degrading to the reliable `send` path is correct: the
432
+ // message still arrives, just ordered/reliable.
433
+ if (this.channel.sendUnreliable) {
434
+ this.channel.sendUnreliable({ kind: 'message', type, payload });
435
+ return;
436
+ }
372
437
  this.send(type, payload);
373
438
  }
374
439
 
@@ -450,11 +515,44 @@ export class CompatRoom {
450
515
  }
451
516
  }
452
517
 
518
+ /** Decode base64-carried `@colyseus/schema` bytes into the live decoder state. */
519
+ private applyEncodedState(base64: string): void {
520
+ if (!this.decoder) return;
521
+ this.decoder.decode(base64ToBytes(base64));
522
+ this.state = this.decoder.state as unknown as Record<string, unknown>;
523
+ }
524
+
525
+ /**
526
+ * Replace the decoder with a fresh root before applying a FULL snapshot. A
527
+ * full snapshot (`encodeAll`) emits only ADDs for current entries and never
528
+ * removals; decoding it onto the already-populated decoder would leave stale
529
+ * "ghost" entries for anything the host dropped since the last sync. A fresh
530
+ * root — built from the same locally-registered room class the join decoder
531
+ * used — reflects EXACTLY the snapshot.
532
+ *
533
+ * Consequence: this swaps the decoder the `$`/Callbacks proxy is bound to, so
534
+ * persistent `$(state)....onAdd/onChange` handlers registered before a resync
535
+ * stop firing afterward and would need re-registration. Accepted because a
536
+ * ghost entry is data corruption (correctness outranks callback persistence)
537
+ * and a full-snapshot resync is rare (state patches ride the ordered-reliable
538
+ * channel — gaps essentially only follow real loss or a setState-replace). If
539
+ * that ever bites a real game, the parity-correct fix is to reconcile the live
540
+ * decoder's collections in place instead of swapping the root.
541
+ */
542
+ private rebuildDecoder(): void {
543
+ const fresh = buildDecoder(this.name);
544
+ if (!fresh) return;
545
+ this.decoder = fresh;
546
+ this.serializer.decoder = fresh;
547
+ this.state = fresh.state as unknown as Record<string, unknown>;
548
+ }
549
+
453
550
  private handleStateSnapshot(envelope: Extract<Envelope, { kind: 'state-snapshot' }>): void {
454
551
  if (envelope.clock <= this.clock) return;
455
552
  this.clock = envelope.clock;
456
- this.replication.applySnapshot(envelope.snapshot.state);
457
- this.state = this.replication.state;
553
+ // FULL snapshot only (resync / setState-replace) — clear ghosts, then decode.
554
+ this.rebuildDecoder();
555
+ this.applyEncodedState(envelope.state);
458
556
  this.onStateChange.emit(this.state);
459
557
  }
460
558
 
@@ -465,9 +563,7 @@ export class CompatRoom {
465
563
  return;
466
564
  }
467
565
  this.clock = envelope.clock;
468
- const patched = applyStatePatch(this.replication.state, envelope.patch);
469
- this.replication.applySnapshot(patched);
470
- this.state = this.replication.state;
566
+ this.applyEncodedState(envelope.patch);
471
567
  this.onStateChange.emit(this.state);
472
568
  }
473
569