@colyseus/core 0.17.43 → 0.18.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.
Files changed (95) hide show
  1. package/build/MatchMaker.cjs +19 -6
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.d.ts +10 -0
  4. package/build/MatchMaker.mjs +18 -6
  5. package/build/MatchMaker.mjs.map +2 -2
  6. package/build/Protocol.cjs +102 -37
  7. package/build/Protocol.cjs.map +2 -2
  8. package/build/Protocol.d.ts +33 -2
  9. package/build/Protocol.mjs +102 -37
  10. package/build/Protocol.mjs.map +2 -2
  11. package/build/Room.cjs +296 -19
  12. package/build/Room.cjs.map +3 -3
  13. package/build/Room.d.ts +186 -3
  14. package/build/Room.mjs +303 -21
  15. package/build/Room.mjs.map +3 -3
  16. package/build/RoomPlugin.cjs +252 -0
  17. package/build/RoomPlugin.cjs.map +7 -0
  18. package/build/RoomPlugin.d.ts +271 -0
  19. package/build/RoomPlugin.mjs +220 -0
  20. package/build/RoomPlugin.mjs.map +7 -0
  21. package/build/Server.cjs +49 -15
  22. package/build/Server.cjs.map +2 -2
  23. package/build/Server.d.ts +25 -0
  24. package/build/Server.mjs +50 -16
  25. package/build/Server.mjs.map +2 -2
  26. package/build/Transport.cjs +38 -2
  27. package/build/Transport.cjs.map +2 -2
  28. package/build/Transport.d.ts +40 -4
  29. package/build/Transport.mjs +38 -2
  30. package/build/Transport.mjs.map +2 -2
  31. package/build/index.cjs +11 -2
  32. package/build/index.cjs.map +2 -2
  33. package/build/index.d.ts +2 -1
  34. package/build/index.mjs +12 -2
  35. package/build/index.mjs.map +2 -2
  36. package/build/input/InputBuffer.cjs +113 -0
  37. package/build/input/InputBuffer.cjs.map +7 -0
  38. package/build/input/InputBuffer.d.ts +136 -0
  39. package/build/input/InputBuffer.mjs +86 -0
  40. package/build/input/InputBuffer.mjs.map +7 -0
  41. package/build/internal.cjs +61 -0
  42. package/build/internal.cjs.map +7 -0
  43. package/build/internal.d.ts +9 -0
  44. package/build/internal.mjs +29 -0
  45. package/build/internal.mjs.map +7 -0
  46. package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
  47. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
  48. package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
  49. package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
  50. package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
  51. package/build/matchmaker/driver.cjs.map +1 -1
  52. package/build/matchmaker/driver.d.ts +12 -0
  53. package/build/matchmaker/driver.mjs.map +1 -1
  54. package/build/presence/LocalPresence.d.ts +1 -1
  55. package/build/rooms/LobbyRoom.cjs +8 -10
  56. package/build/rooms/LobbyRoom.cjs.map +2 -2
  57. package/build/rooms/LobbyRoom.d.ts +4 -3
  58. package/build/rooms/LobbyRoom.mjs +8 -10
  59. package/build/rooms/LobbyRoom.mjs.map +2 -2
  60. package/build/rooms/RelayRoom.cjs +12 -16
  61. package/build/rooms/RelayRoom.cjs.map +2 -2
  62. package/build/rooms/RelayRoom.d.ts +32 -11
  63. package/build/rooms/RelayRoom.mjs +10 -16
  64. package/build/rooms/RelayRoom.mjs.map +2 -2
  65. package/build/router/index.cjs +65 -4
  66. package/build/router/index.cjs.map +2 -2
  67. package/build/router/index.d.ts +30 -6
  68. package/build/router/index.mjs +66 -6
  69. package/build/router/index.mjs.map +3 -3
  70. package/build/utils/Env.cjs +4 -8
  71. package/build/utils/Env.cjs.map +3 -3
  72. package/build/utils/Env.mjs +4 -8
  73. package/build/utils/Env.mjs.map +2 -2
  74. package/build/utils/UserSessionIndex.cjs +162 -0
  75. package/build/utils/UserSessionIndex.cjs.map +7 -0
  76. package/build/utils/UserSessionIndex.d.ts +166 -0
  77. package/build/utils/UserSessionIndex.mjs +130 -0
  78. package/build/utils/UserSessionIndex.mjs.map +7 -0
  79. package/package.json +20 -15
  80. package/src/MatchMaker.ts +40 -6
  81. package/src/Protocol.ts +130 -59
  82. package/src/Room.ts +475 -22
  83. package/src/RoomPlugin.ts +563 -0
  84. package/src/Server.ts +81 -22
  85. package/src/Transport.ts +76 -8
  86. package/src/index.ts +10 -1
  87. package/src/input/InputBuffer.ts +192 -0
  88. package/src/internal.ts +46 -0
  89. package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
  90. package/src/matchmaker/driver.ts +13 -0
  91. package/src/rooms/LobbyRoom.ts +12 -8
  92. package/src/rooms/RelayRoom.ts +9 -15
  93. package/src/router/index.ts +112 -11
  94. package/src/utils/Env.ts +4 -12
  95. package/src/utils/UserSessionIndex.ts +311 -0
package/src/Room.ts CHANGED
@@ -1,5 +1,15 @@
1
- import { unpack } from '@colyseus/msgpackr';
2
- import { decode, type Iterator, $changes } from '@colyseus/schema';
1
+ import { unpack } from 'msgpackr';
2
+ import { decode, Encoder, Reflection, type Iterator, $changes } from '@colyseus/schema';
3
+ import { InputDecoder } from '@colyseus/schema/input';
4
+ import { type InputAccessor, type InputAPI, type InputOptions, type NumericFieldsOf, InputAccessorImpl, InputBufferImpl, NO_OP_INPUT_ACCESSOR } from './input/InputBuffer.ts';
5
+ export { type InputAccessor, type InputAPI, type InputOptions, type NumericFieldsOf } from './input/InputBuffer.ts';
6
+
7
+ /**
8
+ * Module-level cache of `Reflection.encode` output keyed by input
9
+ * constructor — pays the encoding cost once per Room class regardless of
10
+ * room instance count. WeakMap so unused classes can be GC'd.
11
+ */
12
+ const _inputReflectionCache = new WeakMap<Function, Uint8Array>();
3
13
  import { ClockTimer as Clock } from '@colyseus/timer';
4
14
 
5
15
  import { EventEmitter } from 'events';
@@ -28,16 +38,43 @@ import * as matchMaker from './MatchMaker.ts';
28
38
  import {
29
39
  CloseCode,
30
40
  ErrorCode,
41
+ HandshakeSection,
31
42
  Protocol,
43
+ ResponseStatus,
32
44
  type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,
33
45
  type MessageHandler as SharedMessageHandler,
34
46
  type Messages as SharedMessages,
35
47
  } from '@colyseus/shared-types';
36
48
 
49
+ import {
50
+ RoomPlugin,
51
+ setupRoomPlugins,
52
+ type PluginLayout,
53
+ } from './RoomPlugin.ts';
54
+ export {
55
+ RoomPlugin,
56
+ definePlugins,
57
+ attachToTestRoom,
58
+ type RoomPluginOrder,
59
+ } from './RoomPlugin.ts';
60
+
37
61
  const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
38
62
  const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
39
63
  const noneSerializer = new NoneSerializer();
40
64
 
65
+ // Shape an Error (or thrown value) into a plain, msgpack-friendly object for a
66
+ // ROOM_RESPONSE error payload. Only `name`/`message`/`code` cross the wire —
67
+ // stacks stay on the server.
68
+ function toResponseError(e: any): { name: string; message: string; code?: any } {
69
+ if (e instanceof Error) {
70
+ const code = (e as any).code;
71
+ return (code !== undefined)
72
+ ? { name: e.name, message: e.message, code }
73
+ : { name: e.name, message: e.message };
74
+ }
75
+ return { name: "Error", message: String(e) };
76
+ }
77
+
41
78
  export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
42
79
 
43
80
  export type SimulationCallback = (deltaTime: number) => void;
@@ -46,12 +83,23 @@ export interface RoomOptions {
46
83
  state?: object;
47
84
  metadata?: any;
48
85
  client?: Client;
86
+ /**
87
+ * Schema class for client→server input packets. When set, the Room
88
+ * allocates one instance per joining client and binds an InputDecoder.
89
+ * Must be a flat Schema (primitive fields only — see InputEncoder docs).
90
+ *
91
+ * Typed loosely (no `Schema` constraint) to avoid type-identity clashes
92
+ * when the user's app loads a different copy of `@colyseus/schema` than
93
+ * `@colyseus/core` does. Runtime validation happens via the encoder.
94
+ */
95
+ input?: any;
49
96
  }
50
97
 
51
98
  // Helper types to extract individual properties from RoomOptions
52
99
  export type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;
53
100
  export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
54
101
  export type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;
102
+ export type ExtractRoomInput<T> = T extends { input?: infer I } ? I : never;
55
103
 
56
104
  export interface IBroadcastOptions extends ISendOptions {
57
105
  except?: Client | Client[];
@@ -254,6 +302,47 @@ export class Room<T extends RoomOptions = RoomOptions> {
254
302
 
255
303
  public messages?: Messages<any>;
256
304
 
305
+ /**
306
+ * Room plugins, keyed by an operator-chosen handle. Each plugin
307
+ * contributes any subset of: declarative message handlers (merged
308
+ * into `this.messages`), lifecycle hooks (composed with the room's
309
+ * own), and public methods callable via `this.plugins.<key>.X()`.
310
+ *
311
+ * The framework walks this record once per Room subclass to compute
312
+ * the lifecycle/message layout and install hook wrappers on the
313
+ * class prototype; subsequent constructs reuse the cached layout
314
+ * and just inject `.room` + merge messages.
315
+ *
316
+ * Use `definePlugins({...})` so TypeScript preserves each plugin's
317
+ * literal instance type. Frozen after `__init`.
318
+ */
319
+ public plugins?: any;
320
+
321
+ /**
322
+ * Auto-included plugin instances pulled in via `static
323
+ * dependencies` declarations on user-registered plugins. Kept
324
+ * separate from `this.plugins` so the user's typed view doesn't
325
+ * gain framework-managed keys. Sentinel-keyed (`__dep:<ClassName>`)
326
+ * so the hook wrappers can route lookups to the right map.
327
+ *
328
+ * @internal
329
+ */
330
+ public _autoPlugins?: Record<string, RoomPlugin<any>>;
331
+
332
+ /**
333
+ * Layout cache populated on the FIRST construction of each Room
334
+ * subclass. Holds the precomputed hook participation order + message
335
+ * key → plugin key mapping. Stored on the constructor (a static field)
336
+ * so all instances of the same class share it.
337
+ *
338
+ * `null` is a sentinel meaning "no plugins on this class" — distinct
339
+ * from `undefined` ("not yet computed") so we don't re-walk an empty
340
+ * plugin record on every construct.
341
+ *
342
+ * @internal
343
+ */
344
+ static __pluginLayout?: PluginLayout | null;
345
+
257
346
  private onMessageEvents = createNanoEvents();
258
347
  private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
259
348
 
@@ -379,6 +468,15 @@ export class Room<T extends RoomOptions = RoomOptions> {
379
468
  this.state = this.#_state;
380
469
  }
381
470
 
471
+ // Wire room plugins from the instance-level `this.plugins` record.
472
+ // The heavy lifting (conflict detection, hook participation, hook
473
+ // wrapping on the prototype) runs once per class — see
474
+ // `setupRoomPlugins` in `./RoomPlugin.ts`. Per-instance: set
475
+ // `plugin.room = this`, instantiate auto-deps, merge messages.
476
+ if (this.plugins !== undefined) {
477
+ setupRoomPlugins(this);
478
+ }
479
+
382
480
  // Bind messages to the room
383
481
  if (this.messages !== undefined) {
384
482
 
@@ -405,6 +503,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
405
503
  this.clock.start();
406
504
  }
407
505
 
506
+
408
507
  /**
409
508
  * The name of the room you provided as first argument for `gameServer.define()`.
410
509
  *
@@ -489,6 +588,86 @@ export class Room<T extends RoomOptions = RoomOptions> {
489
588
  */
490
589
  public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
491
590
 
591
+ /**
592
+ * Per-client input accessor. Set by `defineInput()`. Call `room.input(sessionId)`
593
+ * each tick to read the latest decoded input and/or the buffered snapshot ring
594
+ * for that client.
595
+ *
596
+ * @example
597
+ * ```typescript
598
+ * class FpsRoom extends Room<{ input: MoveInput }> {
599
+ * input = this.defineInput(MoveInput);
600
+ *
601
+ * onCreate() {
602
+ * this.setSimulationInterval(() => {
603
+ * for (const c of this.clients) {
604
+ * const input = this.input(c.sessionId);
605
+ * if (input.latest) this.apply(c, input.latest);
606
+ * // for rollback / lockstep:
607
+ * // const snapshot = input.at(this.clock.ticks);
608
+ * // for (const snapshot of input.drain()) ...
609
+ * }
610
+ * }, 1000 / 30);
611
+ * }
612
+ * }
613
+ * ```
614
+ */
615
+ public input?: InputAPI<ExtractRoomInput<T>>;
616
+
617
+ /**
618
+ * Input configuration. Set via {@link defineInput} only.
619
+ * @internal
620
+ */
621
+ private inputOptions?: InputOptions;
622
+
623
+ /**
624
+ * Declare the input schema and configuration in a single line. Returns the
625
+ * callable accessor that gets assigned to `this.input` — call
626
+ * `this.input(sessionId)` per tick to consume.
627
+ *
628
+ * ```typescript
629
+ * class FpsRoom extends Room<{ input: MoveInput }> {
630
+ * input = this.defineInput(MoveInput, {
631
+ * seqField: "tick", // typed: only numeric fields of MoveInput
632
+ * bufferMaxSize: 64,
633
+ * });
634
+ *
635
+ * // …or without options — defaults to seqField: "seq", bufferMaxSize: 32:
636
+ * // input = this.defineInput(MoveInput);
637
+ * }
638
+ * ```
639
+ *
640
+ * **Defaults** when `opts` (or individual fields) are omitted:
641
+ * - `seqField`: `"seq"` — framework dedupes by `input.seq` if the schema has
642
+ * such a field. Schemas without it gracefully skip dedupe.
643
+ * - `bufferMaxSize`: `32` — enables per-client snapshot buffering for
644
+ * `room.input(sessionId).drain() / .peek() / .at()`. Set to `0` to disable
645
+ * buffering (the `.latest` read still works).
646
+ */
647
+ protected defineInput<C extends new () => any>(
648
+ type: C,
649
+ opts?: {
650
+ seqField?: NumericFieldsOf<InstanceType<C>>;
651
+ bufferMaxSize?: number;
652
+ },
653
+ ): InputAPI<InstanceType<C>> {
654
+ this.inputOptions = {
655
+ ctor: type,
656
+ seqField: opts?.seqField ?? "seq",
657
+ bufferMaxSize: opts?.bufferMaxSize ?? 32,
658
+ };
659
+ if (!_inputReflectionCache.has(type)) {
660
+ // Reflection.encode walks the schema's TypeContext via a one-shot
661
+ // Encoder around a throwaway instance; the bytes are SDK-deserializable
662
+ // back into a constructor through Reflection.decode.
663
+ _inputReflectionCache.set(type, Reflection.encode(new Encoder(new type())));
664
+ }
665
+ return ((sessionId: string): InputAccessor<InstanceType<C>> => {
666
+ const c = this.clients.getById(sessionId) as unknown as ClientPrivate | undefined;
667
+ return (c?._inputAccessor as InputAccessor<InstanceType<C>>) ?? NO_OP_INPUT_ACCESSOR;
668
+ });
669
+ }
670
+
492
671
  /**
493
672
  * This method is called when the room is disposed.
494
673
  */
@@ -653,6 +832,51 @@ export class Room<T extends RoomOptions = RoomOptions> {
653
832
  }
654
833
  }
655
834
 
835
+ /**
836
+ * Run a fixed-rate simulation tagged with a monotonic server tick number.
837
+ * Combine with `room.input(sessionId).at(tick)` to retrieve each client's
838
+ * input *for a specific tick* — the building block for lockstep / rollback
839
+ * netcode.
840
+ *
841
+ * Replaces any previous {@link setSimulationInterval}. The current tick is
842
+ * exposed via {@link tick}.
843
+ *
844
+ * @example
845
+ * ```typescript
846
+ * class LockstepRoom extends Room<{ input: MoveInput }> {
847
+ * input = this.defineInput(MoveInput, { seqField: "tick", bufferMaxSize: 64 });
848
+ *
849
+ * onCreate() {
850
+ * this.setTickedSimulation((tick, dt) => {
851
+ * for (const c of this.clients) {
852
+ * const snapshot = this.input(c.sessionId).at(tick);
853
+ * if (snapshot) this.apply(c, snapshot);
854
+ * // else: predict, freeze, etc. — game-level decision
855
+ * }
856
+ * }, 1000 / 60);
857
+ * }
858
+ * }
859
+ * ```
860
+ */
861
+ public setTickedSimulation(
862
+ onTickCallback: (tick: number, deltaTime: number) => void,
863
+ delay: number = DEFAULT_SIMULATION_INTERVAL,
864
+ startTick: number = 0,
865
+ ): void {
866
+ this.#_tick = startTick;
867
+ this.setSimulationInterval((dt) => {
868
+ onTickCallback(this.#_tick, dt);
869
+ this.#_tick++;
870
+ }, delay);
871
+ }
872
+
873
+ /**
874
+ * Current server tick. Incremented by {@link setTickedSimulation} after each
875
+ * tick callback returns. Returns 0 when no ticked simulation is running.
876
+ */
877
+ public get tick(): number { return this.#_tick; }
878
+ #_tick: number = 0;
879
+
656
880
  /**
657
881
  * @deprecated Use `.patchRate=` instead.
658
882
  */
@@ -671,21 +895,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
671
895
  this._serializer = serializer;
672
896
  }
673
897
 
674
- public async setMetadata(meta: Partial<ExtractRoomMetadata<T>>, persist: boolean = true) {
675
- if (!this._listing.metadata) {
676
- this._listing.metadata = meta as ExtractRoomMetadata<T>;
677
-
678
- } else {
679
- for (const field in meta) {
680
- if (!meta.hasOwnProperty(field)) { continue; }
681
- this._listing.metadata[field] = meta[field];
682
- }
683
-
684
- // `MongooseDriver` workaround: persit metadata mutations
685
- if ('markModified' in this._listing) {
686
- (this._listing as any).markModified('metadata');
687
- }
688
- }
898
+ public async setMetadata(meta: ExtractRoomMetadata<T>, persist: boolean = true) {
899
+ this._listing.metadata = meta;
689
900
 
690
901
  if (persist && this._internalState === RoomInternalState.CREATED) {
691
902
  await matchMaker.driver.persist(this._listing);
@@ -735,7 +946,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
735
946
  *
736
947
  * @example
737
948
  * ```typescript
738
- * // Partial metadata update (merges with existing)
949
+ * // Merging with existing metadata: spread `this.metadata` yourself.
950
+ * // `metadata` is always REPLACED (not merged) by setMatchmaking()/setMetadata().
739
951
  * await this.setMatchmaking({
740
952
  * metadata: { ...this.metadata, round: this.metadata.round + 1 }
741
953
  * });
@@ -1042,6 +1254,117 @@ export class Room<T extends RoomOptions = RoomOptions> {
1042
1254
  }
1043
1255
  }
1044
1256
 
1257
+ // ---------------------------------------------------------------------------
1258
+ // Operator API — used by @colyseus/admin (and monitor in due course)
1259
+ // through `remoteRoomCall(roomId, methodName)`. Marked `@internal` because
1260
+ // they're framework-tooling primitives, not part of the game-code surface.
1261
+ // ---------------------------------------------------------------------------
1262
+
1263
+ /**
1264
+ * Snapshot the room's live state for an inspector / admin UI. Includes:
1265
+ *
1266
+ * roomId, name, maxClients, locked, elapsedTime (ms),
1267
+ * metadata, clients (sessionId + per-client elapsed + userId when set),
1268
+ * state (the schema/json the SDK would see),
1269
+ * stateSize (bytes of the encoded full state, or 0 when no serializer).
1270
+ *
1271
+ * The payload is intentionally plain JSON — `remoteRoomCall` serializes
1272
+ * the return value across process boundaries.
1273
+ *
1274
+ * @internal Operator-only. Game code should not call this.
1275
+ */
1276
+ public getInspectorView(): {
1277
+ roomId: string;
1278
+ name: string;
1279
+ clients: number;
1280
+ maxClients: number;
1281
+ locked: boolean;
1282
+ elapsedTime: number;
1283
+ metadata: any;
1284
+ clientList: Array<{
1285
+ sessionId: string;
1286
+ userId: string | null;
1287
+ userEmail: string | null;
1288
+ elapsedTime: number;
1289
+ }>;
1290
+ state: any;
1291
+ stateSize: number;
1292
+ } {
1293
+ const elapsed = this.clock.elapsedTime;
1294
+ return {
1295
+ roomId: this.roomId,
1296
+ name: this.roomName,
1297
+ clients: this.clients.length,
1298
+ maxClients: this.maxClients,
1299
+ locked: this.#_locked,
1300
+ elapsedTime: elapsed,
1301
+ metadata: this.metadata ?? null,
1302
+ // Cast through `unknown`: `ExtractRoomClient<T>` vs `Client & ClientPrivate`
1303
+ // don't structurally overlap (the user's `client` generic may carry a
1304
+ // narrower userData/auth shape), but the runtime objects we walk here
1305
+ // always have the private join-time field. The narrow per-property
1306
+ // `(c as any)` reads below keep the cast scoped.
1307
+ //
1308
+ // userId / userEmail read straight off `client.auth` — the JWT
1309
+ // payload @colyseus/auth's default onAuth decodes carries both
1310
+ // when the user has them on file. Saves the admin a per-client
1311
+ // database round-trip; falls back to null when the client signed
1312
+ // in anonymously (or with a custom onAuth that returns a shape
1313
+ // without those fields).
1314
+ clientList: (this.clients as unknown as ReadonlyArray<Client & ClientPrivate>).map((c) => {
1315
+ const auth = (c as any).auth;
1316
+ return {
1317
+ sessionId: c.sessionId,
1318
+ userId: ((c as any).userId ?? auth?.id) ?? null,
1319
+ userEmail: auth?.email ?? null,
1320
+ elapsedTime: elapsed - ((c as any)._joinedAt ?? elapsed),
1321
+ };
1322
+ }),
1323
+ state: this.state ?? null,
1324
+ stateSize: this.#_inspectorStateSize(),
1325
+ };
1326
+ }
1327
+
1328
+ /**
1329
+ * Force-disconnect a single client by sessionId. No-op when the client
1330
+ * isn't connected (idempotent — the caller doesn't need to race-check).
1331
+ *
1332
+ * @internal Operator-only. Game code disconnects clients by calling
1333
+ * `.leave()` on the Client object directly.
1334
+ */
1335
+ public kickClient(sessionId: string, closeCode: number = CloseCode.CONSENTED, reason?: string): void {
1336
+ // Same `unknown` indirection as in `getInspectorView` above —
1337
+ // ExtractRoomClient<T> doesn't structurally include ClientPrivate.
1338
+ for (const client of this.clients as unknown as ReadonlyArray<Client & ClientPrivate>) {
1339
+ if (client.sessionId === sessionId) {
1340
+ this.#_forciblyCloseClient(client as ExtractRoomClient<T> & ClientPrivate, closeCode, reason);
1341
+ return;
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ /**
1347
+ * Best-effort byte size of the current full state. Falls back to `0`
1348
+ * when the room has no serializer or the serializer can't produce a
1349
+ * payload (raw rooms, very-early-onCreate, etc.). The serializer
1350
+ * detection mirrors `@colyseus/monitor`'s — we read whichever buffer
1351
+ * is available across schema v2 and v3.
1352
+ */
1353
+ #_inspectorStateSize(): number {
1354
+ const ser = this._serializer as any;
1355
+ if (!ser) { return 0; }
1356
+ const hasState = ser.encoder || ser.state;
1357
+ if (!hasState) { return 0; }
1358
+ try {
1359
+ const full = ser.getFullState?.();
1360
+ if (!full) { return 0; }
1361
+ // Buffer / Uint8Array have `byteLength`; raw arrays have `length`.
1362
+ return (full as any).byteLength ?? (full as any).length ?? 0;
1363
+ } catch {
1364
+ return 0;
1365
+ }
1366
+ }
1367
+
1045
1368
  /**
1046
1369
  * Disconnect all connected clients, and then dispose the room.
1047
1370
  *
@@ -1103,6 +1426,19 @@ export class Room<T extends RoomOptions = RoomOptions> {
1103
1426
  // (each new reconnection receives a new reconnection token)
1104
1427
  client.reconnectionToken = generateId();
1105
1428
 
1429
+ // Allocate per-client input instance + decoder if the Room called `defineInput()`.
1430
+ // Done early so onJoin can reference room.input(sessionId).latest.
1431
+ if (this.inputOptions !== undefined) {
1432
+ client._input = new this.inputOptions.ctor();
1433
+ client._inputDecoder = new InputDecoder(client._input);
1434
+ // Buffer is opt-in via bufferMaxSize > 0 (rollback / lockstep).
1435
+ const maxSize = this.inputOptions.bufferMaxSize;
1436
+ if (maxSize > 0) {
1437
+ client._inputBuffer = new InputBufferImpl(maxSize, this.inputOptions.seqField);
1438
+ }
1439
+ client._inputAccessor = new InputAccessorImpl(client);
1440
+ }
1441
+
1106
1442
  if (this._reservedSeatTimeouts[sessionId]) {
1107
1443
  clearTimeout(this._reservedSeatTimeouts[sessionId]);
1108
1444
  delete this._reservedSeatTimeouts[sessionId];
@@ -1289,6 +1625,17 @@ export class Room<T extends RoomOptions = RoomOptions> {
1289
1625
  // allow client to send messages after onJoin has succeeded.
1290
1626
  client.ref.on('message', this._onMessage.bind(this, client));
1291
1627
 
1628
+ // Append input reflection as a tagged section when the room declared
1629
+ // input. The SDK uses these bytes to materialize a constructor for
1630
+ // `conn.input()` calls that don't pass an explicit `type`.
1631
+ let extraSections: Array<{ tag: number; bytes: Uint8Array }> | undefined;
1632
+ if (!connectionOptions?.skipHandshake && this.inputOptions !== undefined) {
1633
+ const inputBytes = _inputReflectionCache.get(this.inputOptions.ctor);
1634
+ if (inputBytes !== undefined) {
1635
+ extraSections = [{ tag: HandshakeSection.INPUT_REFLECTION, bytes: inputBytes }];
1636
+ }
1637
+ }
1638
+
1292
1639
  // confirm room id that matches the room name requested to join
1293
1640
  client.raw(getMessageBytes[Protocol.JOIN_ROOM](
1294
1641
  client.reconnectionToken,
@@ -1300,6 +1647,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1300
1647
  (connectionOptions?.skipHandshake)
1301
1648
  ? undefined
1302
1649
  : this._serializer.handshake && this._serializer.handshake(),
1650
+ extraSections,
1303
1651
  ));
1304
1652
  }
1305
1653
  }
@@ -1404,7 +1752,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1404
1752
  // switching Wi-Fi), as the original connection may still be open while a
1405
1753
  // new reconnection attempt is being made.
1406
1754
  //
1407
- if (this._reconnectionAttempts[reconnectionToken]) {
1755
+ if (this._reconnectionAttempts[reconnectionToken] !== undefined) {
1408
1756
  debugMatchMaking('resolving reconnection attempt for client - sessionId: \'%s\', roomId: \'%s\'', sessionId, this.roomId);
1409
1757
  this._reconnectionAttempts[reconnectionToken].resolve(true);
1410
1758
  }
@@ -1448,6 +1796,13 @@ export class Room<T extends RoomOptions = RoomOptions> {
1448
1796
  }
1449
1797
  }
1450
1798
 
1799
+ // Encode and enqueue a ROOM_RESPONSE for a client request. If the client has
1800
+ // already left, `enqueueRaw` is a no-op — no response is needed.
1801
+ #replyToRequest(client: Client, requestId: number, status: ResponseStatus, payload?: any) {
1802
+ debugMessage("response #%d: status=%d -> %j (roomId: %s)", requestId, status, payload, this.roomId);
1803
+ client.enqueueRaw(getMessageBytes[Protocol.ROOM_RESPONSE](requestId, status, payload));
1804
+ }
1805
+
1451
1806
  private sendFullState(client: Client): void {
1452
1807
  client.raw(this._serializer.getFullState(client));
1453
1808
  }
@@ -1595,6 +1950,23 @@ export class Room<T extends RoomOptions = RoomOptions> {
1595
1950
  return await (userReturnData || Promise.resolve());
1596
1951
  }
1597
1952
 
1953
+ /**
1954
+ * After the decoder has mutated `client._input`, push a clone into the
1955
+ * per-client buffer (when buffering is enabled). Honors
1956
+ * `inputOptions.seqField` for dedupe of redundant frames.
1957
+ */
1958
+ #captureInput(client: ClientPrivate) {
1959
+ const buf = client._inputBuffer;
1960
+ if (!buf) { return; } // no consumer registered — skip the clone allocation
1961
+ const inst = client._input!;
1962
+ const seqField = this.inputOptions?.seqField;
1963
+ if (seqField !== undefined) {
1964
+ const value = (inst as any)[seqField] as number;
1965
+ if (typeof value === 'number' && !buf.accept(value)) { return; }
1966
+ }
1967
+ buf.push(inst.clone() as any);
1968
+ }
1969
+
1598
1970
  private _onMessage(client: ExtractRoomClient<T> & ClientPrivate, buffer: Buffer) {
1599
1971
  // skip if client is on LEAVING state.
1600
1972
  if (client.state === ClientState.LEAVING) { return; }
@@ -1650,6 +2022,67 @@ export class Room<T extends RoomOptions = RoomOptions> {
1650
2022
  this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
1651
2023
  }
1652
2024
 
2025
+ } else if (code === Protocol.ROOM_REQUEST) {
2026
+ // A request reuses the same `onMessage(type, ...)` handlers as a plain
2027
+ // ROOM_DATA message — the only difference is the client opted in to a
2028
+ // reply (by passing a callback / using `room.request()`), so the wire
2029
+ // carries a `requestId` we must echo back. The handler's return value
2030
+ // (awaited) becomes the response payload.
2031
+ const requestId = decode.number(buffer, it);
2032
+
2033
+ const messageType = (decode.stringCheck(buffer, it))
2034
+ ? decode.string(buffer, it)
2035
+ : decode.number(buffer, it);
2036
+
2037
+ let message;
2038
+ try {
2039
+ message = (buffer.byteLength > it.offset)
2040
+ ? unpack(buffer.subarray(it.offset, buffer.byteLength))
2041
+ : undefined;
2042
+ debugMessage("request #%d: '%s' -> %j (roomId: %s)", requestId, messageType, message, this.roomId);
2043
+
2044
+ // custom message validation (shared with the ROOM_DATA path)
2045
+ if (this.onMessageValidators[messageType] !== undefined) {
2046
+ message = standardValidate(this.onMessageValidators[messageType], message);
2047
+ }
2048
+
2049
+ } catch (e: any) {
2050
+ // Reply with an error so the client's pending request settles instead
2051
+ // of timing out. (A plain ROOM_DATA would drop the client here, but a
2052
+ // request has a caller waiting on the other end.)
2053
+ debugAndPrintError(e);
2054
+ this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
2055
+ return;
2056
+ }
2057
+
2058
+ // A request is answered by the FIRST handler registered for its type.
2059
+ // `onMessageEvents.emit` would run every handler and discard returns, so
2060
+ // we invoke directly to capture the value. Wildcard ('*') handlers are
2061
+ // not eligible — their (client, type, message) shape has no response
2062
+ // contract — so a type with only a wildcard handler gets `no_handler`.
2063
+ const handler = this.onMessageEvents.events[messageType as string]?.[0];
2064
+
2065
+ if (handler === undefined) {
2066
+ this.#replyToRequest(client, requestId, ResponseStatus.ERROR, {
2067
+ name: "no_handler",
2068
+ message: `room "${this.roomName}" has no onMessage("${messageType}") handler to answer this request.`,
2069
+ });
2070
+ return;
2071
+ }
2072
+
2073
+ // `Promise.resolve().then(...)` normalizes sync throws and async
2074
+ // rejections into the same rejection path. When `onUncaughtException`
2075
+ // is configured the handler is wrapped (see `onMessage`) and swallows
2076
+ // its own errors, so the request resolves with `undefined` and the
2077
+ // exception is reported there instead of as an ERROR response.
2078
+ Promise.resolve().then(() => handler(client, message)).then(
2079
+ (response) => this.#replyToRequest(client, requestId, ResponseStatus.OK, response),
2080
+ (e) => {
2081
+ debugAndPrintError(e);
2082
+ this.#replyToRequest(client, requestId, ResponseStatus.ERROR, toResponseError(e));
2083
+ },
2084
+ );
2085
+
1653
2086
  } else if (code === Protocol.ROOM_DATA_BYTES) {
1654
2087
  const messageType = (decode.stringCheck(buffer, it))
1655
2088
  ? decode.string(buffer, it)
@@ -1681,6 +2114,27 @@ export class Room<T extends RoomOptions = RoomOptions> {
1681
2114
  this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
1682
2115
  }
1683
2116
 
2117
+ } else if (code === Protocol.ROOM_INPUT_RELIABLE) {
2118
+ if (client._inputDecoder) {
2119
+ try {
2120
+ client._inputDecoder.decode(buffer.subarray(1));
2121
+ } catch (e: any) {
2122
+ debugAndPrintError(e);
2123
+ return;
2124
+ }
2125
+ this.#captureInput(client);
2126
+ }
2127
+
2128
+ } else if (code === Protocol.ROOM_INPUT_UNRELIABLE) {
2129
+ if (client._inputDecoder) {
2130
+ try {
2131
+ client._inputDecoder.decodeAll(buffer.subarray(1), () => this.#captureInput(client));
2132
+ } catch (e: any) {
2133
+ debugAndPrintError(e);
2134
+ return;
2135
+ }
2136
+ }
2137
+
1684
2138
  } else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
1685
2139
  // join room has been acknowledged by the client
1686
2140
  client.state = ClientState.JOINED;
@@ -1705,7 +2159,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1705
2159
  }
1706
2160
  }
1707
2161
 
1708
- #_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number) {
2162
+ #_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number, reason?: string) {
1709
2163
  // stop receiving messages from this client
1710
2164
  client.ref.removeAllListeners('message');
1711
2165
 
@@ -1713,7 +2167,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1713
2167
  client.ref.removeListener('close', client.ref['onleave']);
1714
2168
 
1715
2169
  // only effectively close connection when "onLeave" is fulfilled
1716
- this._onLeave(client, closeCode).then(() => client.leave(closeCode));
2170
+ this._onLeave(client, closeCode).then(() => (client as any).leave(closeCode, reason));
1717
2171
  }
1718
2172
 
1719
2173
  private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
@@ -1771,7 +2225,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
1771
2225
  if (this._reservedSeats[client.sessionId] === undefined) {
1772
2226
  this._events.emit('leave', client, willDispose);
1773
2227
  }
1774
-
1775
2228
  }
1776
2229
 
1777
2230
  async #_incrementClientCount() {