@colyseus/core 0.17.44 → 0.18.1

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 (157) hide show
  1. package/build/MatchMaker.cjs +22 -13
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.d.ts +10 -0
  4. package/build/MatchMaker.mjs +22 -14
  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/Rewind.cjs +385 -0
  12. package/build/Rewind.cjs.map +7 -0
  13. package/build/Rewind.d.ts +291 -0
  14. package/build/Rewind.mjs +359 -0
  15. package/build/Rewind.mjs.map +7 -0
  16. package/build/Room.cjs +425 -149
  17. package/build/Room.cjs.map +3 -3
  18. package/build/Room.d.ts +314 -27
  19. package/build/Room.mjs +434 -152
  20. package/build/Room.mjs.map +3 -3
  21. package/build/RoomMessages.cjs +265 -0
  22. package/build/RoomMessages.cjs.map +7 -0
  23. package/build/RoomMessages.d.ts +49 -0
  24. package/build/RoomMessages.mjs +240 -0
  25. package/build/RoomMessages.mjs.map +7 -0
  26. package/build/RoomPlugin.cjs +252 -0
  27. package/build/RoomPlugin.cjs.map +7 -0
  28. package/build/RoomPlugin.d.ts +271 -0
  29. package/build/RoomPlugin.mjs +220 -0
  30. package/build/RoomPlugin.mjs.map +7 -0
  31. package/build/Server.cjs +49 -11
  32. package/build/Server.cjs.map +2 -2
  33. package/build/Server.d.ts +26 -0
  34. package/build/Server.mjs +50 -12
  35. package/build/Server.mjs.map +2 -2
  36. package/build/Transport.cjs +64 -2
  37. package/build/Transport.cjs.map +2 -2
  38. package/build/Transport.d.ts +108 -5
  39. package/build/Transport.mjs +63 -2
  40. package/build/Transport.mjs.map +2 -2
  41. package/build/errors/RoomExceptions.cjs +7 -4
  42. package/build/errors/RoomExceptions.cjs.map +2 -2
  43. package/build/errors/RoomExceptions.d.ts +15 -3
  44. package/build/errors/RoomExceptions.mjs +5 -3
  45. package/build/errors/RoomExceptions.mjs.map +2 -2
  46. package/build/index.cjs +30 -4
  47. package/build/index.cjs.map +2 -2
  48. package/build/index.d.ts +9 -5
  49. package/build/index.mjs +29 -6
  50. package/build/index.mjs.map +2 -2
  51. package/build/input/InputBuffer.cjs +536 -0
  52. package/build/input/InputBuffer.cjs.map +7 -0
  53. package/build/input/InputBuffer.d.ts +226 -0
  54. package/build/input/InputBuffer.mjs +507 -0
  55. package/build/input/InputBuffer.mjs.map +7 -0
  56. package/build/input/RoomInput.cjs +306 -0
  57. package/build/input/RoomInput.cjs.map +7 -0
  58. package/build/input/RoomInput.d.ts +91 -0
  59. package/build/input/RoomInput.mjs +287 -0
  60. package/build/input/RoomInput.mjs.map +7 -0
  61. package/build/input/types.cjs +18 -0
  62. package/build/input/types.cjs.map +7 -0
  63. package/build/input/types.d.ts +476 -0
  64. package/build/input/types.mjs +0 -0
  65. package/build/input/types.mjs.map +7 -0
  66. package/build/internal.cjs +61 -0
  67. package/build/internal.cjs.map +7 -0
  68. package/build/internal.d.ts +9 -0
  69. package/build/internal.mjs +29 -0
  70. package/build/internal.mjs.map +7 -0
  71. package/build/matchmaker/LocalDriver/LocalDriver.cjs +13 -0
  72. package/build/matchmaker/LocalDriver/LocalDriver.cjs.map +2 -2
  73. package/build/matchmaker/LocalDriver/LocalDriver.d.ts +1 -0
  74. package/build/matchmaker/LocalDriver/LocalDriver.mjs +13 -0
  75. package/build/matchmaker/LocalDriver/LocalDriver.mjs.map +2 -2
  76. package/build/matchmaker/driver.cjs.map +1 -1
  77. package/build/matchmaker/driver.d.ts +12 -0
  78. package/build/matchmaker/driver.mjs.map +1 -1
  79. package/build/presence/LocalPresence.d.ts +1 -1
  80. package/build/rooms/LobbyRoom.cjs +8 -10
  81. package/build/rooms/LobbyRoom.cjs.map +2 -2
  82. package/build/rooms/LobbyRoom.d.ts +4 -3
  83. package/build/rooms/LobbyRoom.mjs +8 -10
  84. package/build/rooms/LobbyRoom.mjs.map +2 -2
  85. package/build/rooms/QueueRoom.cjs +1 -1
  86. package/build/rooms/QueueRoom.cjs.map +2 -2
  87. package/build/rooms/QueueRoom.mjs +1 -1
  88. package/build/rooms/QueueRoom.mjs.map +2 -2
  89. package/build/rooms/RelayRoom.cjs +12 -16
  90. package/build/rooms/RelayRoom.cjs.map +2 -2
  91. package/build/rooms/RelayRoom.d.ts +32 -11
  92. package/build/rooms/RelayRoom.mjs +10 -16
  93. package/build/rooms/RelayRoom.mjs.map +2 -2
  94. package/build/router/default_routes.cjs +4 -1
  95. package/build/router/default_routes.cjs.map +2 -2
  96. package/build/router/default_routes.mjs +4 -1
  97. package/build/router/default_routes.mjs.map +2 -2
  98. package/build/router/index.cjs +71 -5
  99. package/build/router/index.cjs.map +2 -2
  100. package/build/router/index.d.ts +30 -6
  101. package/build/router/index.mjs +72 -7
  102. package/build/router/index.mjs.map +3 -3
  103. package/build/serializer/NoneSerializer.cjs +2 -2
  104. package/build/serializer/NoneSerializer.cjs.map +2 -2
  105. package/build/serializer/NoneSerializer.d.ts +3 -3
  106. package/build/serializer/NoneSerializer.mjs +2 -2
  107. package/build/serializer/NoneSerializer.mjs.map +2 -2
  108. package/build/serializer/SchemaSerializer.cjs +44 -14
  109. package/build/serializer/SchemaSerializer.cjs.map +2 -2
  110. package/build/serializer/SchemaSerializer.d.ts +3 -3
  111. package/build/serializer/SchemaSerializer.mjs +46 -16
  112. package/build/serializer/SchemaSerializer.mjs.map +2 -2
  113. package/build/serializer/Serializer.cjs.map +1 -1
  114. package/build/serializer/Serializer.d.ts +12 -2
  115. package/build/utils/DevMode.cjs +14 -14
  116. package/build/utils/DevMode.cjs.map +2 -2
  117. package/build/utils/DevMode.d.ts +0 -2
  118. package/build/utils/DevMode.mjs +14 -12
  119. package/build/utils/DevMode.mjs.map +2 -2
  120. package/build/utils/UserSessionIndex.cjs +162 -0
  121. package/build/utils/UserSessionIndex.cjs.map +7 -0
  122. package/build/utils/UserSessionIndex.d.ts +166 -0
  123. package/build/utils/UserSessionIndex.mjs +130 -0
  124. package/build/utils/UserSessionIndex.mjs.map +7 -0
  125. package/build/utils/Utils.cjs +3 -0
  126. package/build/utils/Utils.cjs.map +2 -2
  127. package/build/utils/Utils.d.ts +8 -0
  128. package/build/utils/Utils.mjs +2 -0
  129. package/build/utils/Utils.mjs.map +2 -2
  130. package/package.json +19 -14
  131. package/src/MatchMaker.ts +47 -15
  132. package/src/Protocol.ts +130 -59
  133. package/src/Rewind.ts +572 -0
  134. package/src/Room.ts +649 -235
  135. package/src/RoomMessages.ts +342 -0
  136. package/src/RoomPlugin.ts +563 -0
  137. package/src/Server.ts +82 -15
  138. package/src/Transport.ts +172 -9
  139. package/src/errors/RoomExceptions.ts +18 -4
  140. package/src/index.ts +23 -3
  141. package/src/input/InputBuffer.ts +549 -0
  142. package/src/input/RoomInput.ts +337 -0
  143. package/src/input/types.ts +503 -0
  144. package/src/internal.ts +46 -0
  145. package/src/matchmaker/LocalDriver/LocalDriver.ts +10 -0
  146. package/src/matchmaker/driver.ts +13 -0
  147. package/src/rooms/LobbyRoom.ts +12 -8
  148. package/src/rooms/QueueRoom.ts +1 -1
  149. package/src/rooms/RelayRoom.ts +9 -15
  150. package/src/router/default_routes.ts +6 -1
  151. package/src/router/index.ts +117 -11
  152. package/src/serializer/NoneSerializer.ts +3 -3
  153. package/src/serializer/SchemaSerializer.ts +110 -19
  154. package/src/serializer/Serializer.ts +13 -2
  155. package/src/utils/DevMode.ts +18 -13
  156. package/src/utils/UserSessionIndex.ts +311 -0
  157. package/src/utils/Utils.ts +9 -0
package/src/Room.ts CHANGED
@@ -1,5 +1,11 @@
1
- import { unpack } from '@colyseus/msgpackr';
2
1
  import { decode, type Iterator, $changes } from '@colyseus/schema';
2
+ import { validateSubSteps } from './input/InputBuffer.ts';
3
+ import type { InputAPI, DefineInputOptions, IdleDeclared } from './input/types.ts';
4
+ import { RoomInput } from './input/RoomInput.ts';
5
+ import { RoomMessages } from './RoomMessages.ts';
6
+ import { Rewind, type RewindOptions } from './Rewind.ts';
7
+ export { type InputAccessor, type InputAPI, type NormalizedInputOptions, type ConsumeOptions, type IdleInput, type IdleContext, type SanitizeInput, type NumericFieldsOf, type DefineInputOptions, type IdleDeclared } from './input/types.ts';
8
+
3
9
  import { ClockTimer as Clock } from '@colyseus/timer';
4
10
 
5
11
  import { EventEmitter } from 'events';
@@ -14,44 +20,108 @@ import { SchemaSerializer } from './serializer/SchemaSerializer.ts';
14
20
 
15
21
  import { getMessageBytes } from './Protocol.ts';
16
22
  import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';
17
- import { createNanoEvents } from './utils/nanoevents.ts';
18
23
  import { isDevMode } from './utils/DevMode.ts';
19
24
 
20
25
  import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';
21
26
  import { ServerError } from './errors/ServerError.ts';
22
27
  import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';
23
- import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException, OnMessageException, OnReconnectException, type RoomException, SimulationIntervalException, TimedEventException } from './errors/RoomExceptions.ts';
28
+ import { type RoomMethodName, OnAuthException, OnCreateException, OnDisposeException, OnDropException, OnJoinException, OnLeaveException, OnReconnectException, type RoomException, TimestepException, TimedEventException } from './errors/RoomExceptions.ts';
24
29
 
25
- import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';
30
+ import { type StandardSchemaV1 } from './utils/StandardSchema.ts';
26
31
  import * as matchMaker from './MatchMaker.ts';
27
32
 
28
33
  import {
29
34
  CloseCode,
30
35
  ErrorCode,
31
36
  Protocol,
37
+ PROTOCOL_CODE_MASK,
38
+ PROTOCOL_MODIFIER_MASK,
32
39
  type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,
33
40
  type MessageHandler as SharedMessageHandler,
34
41
  type Messages as SharedMessages,
42
+ type MessageContext,
35
43
  } from '@colyseus/shared-types';
36
44
 
45
+ import {
46
+ RoomPlugin,
47
+ setupRoomPlugins,
48
+ type PluginLayout,
49
+ } from './RoomPlugin.ts';
50
+ export {
51
+ RoomPlugin,
52
+ definePlugins,
53
+ attachToTestRoom,
54
+ type RoomPluginOrder,
55
+ } from './RoomPlugin.ts';
56
+
37
57
  const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
38
58
  const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
39
59
  const noneSerializer = new NoneSerializer();
40
60
 
61
+ /** Shared `enqueueRaw` options routing a frame onto `_pendingFrames` to ride the
62
+ * next patch. Frozen + shared (read-only) → zero per-call allocation. */
63
+ const AFTER_PATCH_OPTS = Object.freeze({ afterNextPatch: true });
64
+
41
65
  export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
42
66
 
43
67
  export type SimulationCallback = (deltaTime: number) => void;
44
68
 
69
+ /**
70
+ * Per-step context passed to a {@link Room.setFixedTimestep} callback. Carries
71
+ * ONLY the fixed simulation step — never wall-clock/measured time — so feeding a
72
+ * jittery delta into deterministic sim math is unrepresentable. The same fixed
73
+ * `dt` is advertised to clients so prediction integrates identically. (The
74
+ * client's step context additionally carries an `isReplay` flag for rollback
75
+ * re-simulation; the server is authoritative and never replays, so it has none.)
76
+ */
77
+ export interface StepContext {
78
+ /** Fixed step in SECONDS (`1/tickRate`) — the dt to integrate one step with. */
79
+ readonly dt: number;
80
+ /** Fixed step in MILLISECONDS (`1000/tickRate`). */
81
+ readonly dtMs: number;
82
+ /** Monotonic index of the fixed step being simulated. */
83
+ readonly tick: number;
84
+ /**
85
+ * Physics sub-steps per fixed step (≥ 1; `1` unless declared via
86
+ * `setFixedTimestep(..., { subSteps })`). Run your engine `subSteps` times at
87
+ * {@link subDt} inside each step — `for (let i = 0; i < ctx.subSteps; i++)
88
+ * world.step(ctx.subDt)` — to integrate physics at `tickRate * subSteps` Hz
89
+ * while inputs flow at `tickRate`. The same numbers are cascaded to predicting
90
+ * clients (their step context carries identical `subSteps`/`subDt`), so the
91
+ * sub-stepped trajectory replays bit-identically.
92
+ */
93
+ readonly subSteps: number;
94
+ /** Physics sub-step in SECONDS (`dt / subSteps`); equals {@link dt} when
95
+ * `subSteps` is 1, so the loop above is valid for every room. */
96
+ readonly subDt: number;
97
+ /** Physics sub-step in MILLISECONDS (`dtMs / subSteps`). */
98
+ readonly subDtMs: number;
99
+ }
100
+
101
+ /** Fixed-timestep simulation callback. @see Room.setFixedTimestep */
102
+ export type FixedTimestepCallback = (ctx: StepContext) => void;
103
+
45
104
  export interface RoomOptions {
46
105
  state?: object;
47
106
  metadata?: any;
48
107
  client?: Client;
108
+ /**
109
+ * Schema class for client→server input packets. When set, the Room
110
+ * allocates one instance per joining client and binds an InputDecoder.
111
+ * Must be a flat Schema (primitive fields only — see InputEncoder docs).
112
+ *
113
+ * Typed loosely (no `Schema` constraint) to avoid type-identity clashes
114
+ * when the user's app loads a different copy of `@colyseus/schema` than
115
+ * `@colyseus/core` does. Runtime validation happens via the encoder.
116
+ */
117
+ input?: any;
49
118
  }
50
119
 
51
120
  // Helper types to extract individual properties from RoomOptions
52
121
  export type ExtractRoomState<T> = T extends { state?: infer S extends object } ? S : any;
53
122
  export type ExtractRoomMetadata<T> = T extends { metadata?: infer M } ? M : any;
54
123
  export type ExtractRoomClient<T> = T extends { client?: infer C extends Client } ? C : Client;
124
+ export type ExtractRoomInput<T> = T extends { input?: infer I } ? I : never;
55
125
 
56
126
  export interface IBroadcastOptions extends ISendOptions {
57
127
  except?: Client | Client[];
@@ -86,7 +156,7 @@ export type Messages<This extends Room> = SharedMessages<This, Client>;
86
156
  */
87
157
  export function validate<T extends StandardSchemaV1, This = any>(
88
158
  format: T,
89
- handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void
159
+ handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>, ctx: MessageContext) => unknown
90
160
  ): MessageHandlerWithFormat<T, This> {
91
161
  return { format, handler };
92
162
  }
@@ -253,28 +323,68 @@ export class Room<T extends RoomOptions = RoomOptions> {
253
323
  private _reconnectionAttempts: { [reconnectionToken: string]: Deferred } = {};
254
324
 
255
325
  public messages?: Messages<any>;
326
+ /** @internal Message-routing layer: handler registry + user-message decode/dispatch. */
327
+ #_messages = new RoomMessages(this);
256
328
 
257
- private onMessageEvents = createNanoEvents();
258
- private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
329
+ /**
330
+ * Room plugins, keyed by an operator-chosen handle. Each plugin
331
+ * contributes any subset of: declarative message handlers (merged
332
+ * into `this.messages`), lifecycle hooks (composed with the room's
333
+ * own), and public methods callable via `this.plugins.<key>.X()`.
334
+ *
335
+ * The framework walks this record once per Room subclass to compute
336
+ * the lifecycle/message layout and install hook wrappers on the
337
+ * class prototype; subsequent constructs reuse the cached layout
338
+ * and just inject `.room` + merge messages.
339
+ *
340
+ * Use `definePlugins({...})` so TypeScript preserves each plugin's
341
+ * literal instance type. Frozen after `__init`.
342
+ */
343
+ public plugins?: any;
259
344
 
260
- private onMessageFallbacks = {
261
- '__no_message_handler': (client: ExtractRoomClient<T>, messageType: string | number, _: unknown) => {
262
- const errorMessage = `room onMessage for "${messageType}" not registered.`;
263
- debugMessage(`${errorMessage} (roomId: ${this.roomId})`);
345
+ /**
346
+ * Auto-included plugin instances pulled in via `static
347
+ * dependencies` declarations on user-registered plugins. Kept
348
+ * separate from `this.plugins` so the user's typed view doesn't
349
+ * gain framework-managed keys. Sentinel-keyed (`__dep:<ClassName>`)
350
+ * so the hook wrappers can route lookups to the right map.
351
+ *
352
+ * @internal
353
+ */
354
+ public _autoPlugins?: Record<string, RoomPlugin<any>>;
264
355
 
265
- if (isDevMode) {
266
- // send error code to client in development mode
267
- client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
356
+ /**
357
+ * Layout cache populated on the FIRST construction of each Room
358
+ * subclass. Holds the precomputed hook participation order + message
359
+ * key → plugin key mapping. Stored on the constructor (a static field)
360
+ * so all instances of the same class share it.
361
+ *
362
+ * `null` is a sentinel meaning "no plugins on this class" — distinct
363
+ * from `undefined` ("not yet computed") so we don't re-walk an empty
364
+ * plugin record on every construct.
365
+ *
366
+ * @internal
367
+ */
368
+ static __pluginLayout?: PluginLayout | null;
268
369
 
269
- } else {
270
- // immediately close the connection in production
271
- client.leave(CloseCode.WITH_ERROR, errorMessage);
272
- }
273
- }
274
- };
370
+ // Re-expose the registry for @colyseus/playground introspection and
371
+ // @colyseus/testing handler-swapping (both reach in via bracket access).
372
+ private get onMessageEvents() { return this.#_messages.events; }
373
+ private get onMessageValidators() { return this.#_messages.validators; }
275
374
 
276
375
  private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
277
- private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient<T>, ArrayLike<any>]> = [];
376
+
377
+ /** Clients that staged `afterNextPatch` frames since the last patch, so the
378
+ * post-patch flush iterates only these — never the full client list. Shared by
379
+ * reference onto each client at join. Reset each `broadcastPatch`. */
380
+ #pendingFrameClients: Array<Client & ClientPrivate> = [];
381
+
382
+ /** `broadcast(..., { afterNextPatch })` in NON-timed rooms, sent as a SHARED
383
+ * frame right after the next patch (one encode, N sends). Non-timed patches are
384
+ * themselves a shared buffer, so a per-client copy would force N× allocation in
385
+ * large rooms — sharing avoids that. TIMED rooms instead stage the broadcast onto
386
+ * each client's `_pendingFrames`. Drained each `broadcastPatch`. */
387
+ #afterPatchBroadcasts: Array<{ bytes: Uint8Array; except: Client[] | undefined }> = [];
278
388
 
279
389
  private _simulationInterval: NodeJS.Timeout;
280
390
 
@@ -379,6 +489,15 @@ export class Room<T extends RoomOptions = RoomOptions> {
379
489
  this.state = this.#_state;
380
490
  }
381
491
 
492
+ // Wire room plugins from the instance-level `this.plugins` record.
493
+ // The heavy lifting (conflict detection, hook participation, hook
494
+ // wrapping on the prototype) runs once per class — see
495
+ // `setupRoomPlugins` in `./RoomPlugin.ts`. Per-instance: set
496
+ // `plugin.room = this`, instantiate auto-deps, merge messages.
497
+ if (this.plugins !== undefined) {
498
+ setupRoomPlugins(this);
499
+ }
500
+
382
501
  // Bind messages to the room
383
502
  if (this.messages !== undefined) {
384
503
 
@@ -405,6 +524,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
405
524
  this.clock.start();
406
525
  }
407
526
 
527
+
408
528
  /**
409
529
  * The name of the room you provided as first argument for `gameServer.define()`.
410
530
  *
@@ -489,6 +609,86 @@ export class Room<T extends RoomOptions = RoomOptions> {
489
609
  */
490
610
  public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
491
611
 
612
+ /**
613
+ * Per-client input accessor. Assign it `= this.defineInput(...)`, then call
614
+ * `this.inputs.get(sessionId)` each tick to read that client's buffered input
615
+ * *stream* — iterate it directly, or call `.next()` / `.drain()` / `.latest`.
616
+ *
617
+ * (In the `Room<{ input: I }>` generic, `input` is the input *type* of one
618
+ * frame; `this.inputs` is the per-client accessor over the stream of them.)
619
+ *
620
+ * @example
621
+ * ```typescript
622
+ * class FpsRoom extends Room<{ input: MoveInput }> {
623
+ * inputs = this.defineInput(MoveInput);
624
+ *
625
+ * onCreate() {
626
+ * this.setFixedTimestep((ctx) => {
627
+ * for (const [sid, p] of this.state.players)
628
+ * for (const input of this.inputs.get(sid)) // consume one-by-one
629
+ * applyInput(p, input, ctx.dt);
630
+ * }, 30);
631
+ * }
632
+ * }
633
+ * ```
634
+ */
635
+ public inputs?: InputAPI<ExtractRoomInput<T>>;
636
+
637
+ /**
638
+ * @internal The per-room input subsystem (options, accessor registry, wire
639
+ * stamp mode, encode/decode/handshake). Created lazily on the first
640
+ * {@link defineInput} call — rooms without inputs allocate none of it, and its
641
+ * presence is what enables client-timed state messages. See {@link RoomInput}.
642
+ */
643
+ private _inputController?: RoomInput;
644
+
645
+ /**
646
+ * Declare the input schema and configuration in a single line. Returns the
647
+ * input API that gets assigned to `this.inputs` — call
648
+ * `this.inputs.get(sessionId)` per tick to consume.
649
+ *
650
+ * ```typescript
651
+ * class FpsRoom extends Room<{ input: MoveInput }> {
652
+ * inputs = this.defineInput(MoveInput, {
653
+ * seqField: "tick", // typed: only numeric fields of MoveInput
654
+ * bufferMaxSize: 64,
655
+ * });
656
+ *
657
+ * // …or without options — no seq dedupe, bufferMaxSize: 32:
658
+ * // inputs = this.defineInput(MoveInput);
659
+ * }
660
+ * ```
661
+ *
662
+ * **Defaults** when `opts` (or individual fields) are omitted:
663
+ * - `seqField`: unset — dedupe and `this.inputs.get(sessionId).at(value)` lookup are
664
+ * OPT-IN (lockstep / rollback). Name a monotonic numeric field here to enable
665
+ * them; redundant frames (`input[seqField]` ≤ the last seen) are then dropped.
666
+ * Leave unset for reliable, in-order channels where every frame is unique.
667
+ * - `bufferMaxSize`: `32` — enables per-client snapshot buffering for
668
+ * `this.inputs.get(sessionId)` iteration / `.consume() / .drain() / .next() /
669
+ * .take() / .peek() / .at()`. Set to `0` to disable buffering (the `.latest`
670
+ * read still works).
671
+ *
672
+ * **Consuming the buffer** — `for (const inp of this.inputs.get(sessionId))` (sugar
673
+ * for `.consume()`) is the per-entity loop: it consumes one at a time, so
674
+ * lag-comp `renderTime` tracks each input. `next()` (take exactly one, ack +1)
675
+ * suits a shared physics world stepped once for everyone; `drain()` returns the
676
+ * whole pending set as an array. See {@link InputAccessor} for the full
677
+ * per-entity-vs-shared-world guidance and why the choice affects the reconcile ack.
678
+ */
679
+ protected defineInput<
680
+ C extends new () => any,
681
+ O extends DefineInputOptions<InstanceType<C>> = DefineInputOptions<InstanceType<C>>,
682
+ >(
683
+ type: C,
684
+ opts?: O,
685
+ ): InputAPI<InstanceType<C>, IdleDeclared<O, InstanceType<C>>> {
686
+ // Lazily spun up on first call; its presence is the "real-time room" gate
687
+ // (enables client-timed state). Rooms that never call this pay nothing.
688
+ this._inputController ??= new RoomInput(this);
689
+ return this._inputController.define<C, O>(type, opts);
690
+ }
691
+
492
692
  /**
493
693
  * This method is called when the room is disposed.
494
694
  */
@@ -502,7 +702,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
502
702
  * - `onMessage`
503
703
  * - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`
504
704
  * - `clock.setTimeout` / `clock.setInterval`
505
- * - `setSimulationInterval`
705
+ * - `setTimestep` / `setFixedTimestep`
506
706
  *
507
707
  * (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)
508
708
  */
@@ -604,7 +804,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
604
804
 
605
805
  } else if (typeof(reconnectionToken) === "string") {
606
806
  // potentially a stale client reference, so a reconnection attempt is possible.
607
- return this.clients.getById(sessionId)?.reconnectionToken === reconnectionToken;
807
+ return this.clients.get(sessionId)?.reconnectionToken === reconnectionToken;
608
808
  }
609
809
 
610
810
  return false;
@@ -628,22 +828,29 @@ export class Room<T extends RoomOptions = RoomOptions> {
628
828
  }
629
829
 
630
830
  /**
631
- * (Optional) Set a simulation interval that can change the state of the game.
632
- * The simulation interval is your game loop.
831
+ * Set the room's game loop. `onTickCallback` runs every `delay` ms and receives
832
+ * the MEASURED wall-clock delta since the previous tick (a VARIABLE timestep).
833
+ * For deterministic, prediction-friendly simulation prefer
834
+ * {@link Room.setFixedTimestep}, which advances a fixed step via an accumulator.
633
835
  *
634
836
  * @default 16.6ms (60fps)
635
837
  *
636
- * @param onTickCallback - You can implement your physics or world updates here!
637
- * This is a good place to update the room state.
638
- * @param delay - Interval delay on executing `onTickCallback` in milliseconds.
838
+ * @param onTickCallback - Your physics / world update a good place to mutate
839
+ * room state. Receives the measured delta (`this.clock.deltaTime`).
840
+ * @param delay - Interval between ticks in milliseconds.
639
841
  */
640
- public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
641
- // clear previous interval in case called setSimulationInterval more than once
842
+ public setTimestep(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
843
+ // clear previous loop in case it was set more than once
642
844
  if (this._simulationInterval) { clearInterval(this._simulationInterval); }
643
845
 
846
+ // Advertise this loop's rate to predicting clients unless set explicitly.
847
+ if (onTickCallback && this._inputController && this._inputController.options.tickRate === undefined) {
848
+ this._inputController.options.tickRate = Math.round(1000 / delay);
849
+ }
850
+
644
851
  if (onTickCallback) {
645
852
  if (this.onUncaughtException !== undefined) {
646
- onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, 'setSimulationInterval');
853
+ onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), TimestepException, 'setTimestep');
647
854
  }
648
855
 
649
856
  this._simulationInterval = setInterval(() => {
@@ -653,6 +860,181 @@ export class Room<T extends RoomOptions = RoomOptions> {
653
860
  }
654
861
  }
655
862
 
863
+ /**
864
+ * @deprecated Renamed to {@link Room.setTimestep} (which pairs with
865
+ * {@link Room.setFixedTimestep}). Kept for backwards compatibility — forwards
866
+ * to `setTimestep` unchanged.
867
+ */
868
+ public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
869
+ this.setTimestep(onTickCallback, delay);
870
+ }
871
+
872
+ /**
873
+ * Fixed-timestep game loop with a framework-owned accumulator — the right
874
+ * default for prediction/rollback. Unlike {@link setTimestep} (which
875
+ * hands you the *measured* wall-clock delta), this runs `step` a whole number
876
+ * of times per real frame so each step advances by the SAME fixed
877
+ * `dt = 1/tickRate`; the measured delta only decides HOW MANY steps run. The
878
+ * fixed dt is delivered via {@link StepContext}, so the jittery wall-clock
879
+ * delta can't leak into deterministic simulation.
880
+ *
881
+ * `tickRate` is also the SINGLE SOURCE of the simulation rate: it's advertised
882
+ * to predicting clients via the join handshake (they predict at the matching
883
+ * `dt`), so don't also pass `tickRate` to {@link defineInput}.
884
+ *
885
+ * **`tickRate` couples three rates** — by design, one input == one fixed step
886
+ * == one server tick, so lowering `tickRate` to save bandwidth also lowers
887
+ * the simulation rate. To keep high-fidelity physics on a lower network rate,
888
+ * pass `{ subSteps: N }`: one input still drives one fixed step, but you
889
+ * integrate `N` engine sub-steps of `ctx.subDt` (= `ctx.dt / N`) inside it —
890
+ * physics at `tickRate * N` Hz, inputs at `tickRate`/sec. The same
891
+ * `subSteps`/`subDt` are cascaded to predicting clients (via the join
892
+ * handshake, onto their reconciler's step context), so client replay
893
+ * reproduces the sub-stepped trajectory exactly. Render interpolation already
894
+ * smooths above the step rate — most games don't need this; reach for it when
895
+ * the *simulation* needs the extra Hz (fast projectiles, stacking, tunneling).
896
+ *
897
+ * On a hitch the accumulator runs at most a few catch-up steps then drops the
898
+ * backlog (no spiral of death). Lag-comp is recorded once per real frame.
899
+ *
900
+ * **Consuming input inside the step** — how you consume each client's buffer
901
+ * depends on who integrates (see {@link InputAccessor}):
902
+ * - *Per-entity* (each body integrates itself): `for (const cmd of
903
+ * this.inputs.get(sid)) applyInput(player, cmd, ctx.dt)` — N inputs =
904
+ * N sub-integrations, ack lands on the newest applied, and `renderTime`
905
+ * tracks each input (lag comp stays exact per step).
906
+ * - *Shared world* (one solver step advances every body): consume exactly one
907
+ * input per entity per step with `this.inputs.get(sid).next()` (or `take(n)` +
908
+ * sub-step), then `world.step()` once. Draining all and applying only the
909
+ * latest would jump the reconcile ack past inputs you never simulated.
910
+ *
911
+ * @param step - Called once per fixed step with a {@link StepContext}.
912
+ * @param tickRate - Simulation rate in **Hz**. Defaults to 60.
913
+ * @param opts - `subSteps`: physics sub-steps per fixed step (integer ≥ 1,
914
+ * default 1) — see above.
915
+ *
916
+ * @example
917
+ * ```ts
918
+ * onCreate() {
919
+ * // 30 inputs/sec on the wire, physics integrated at 60 Hz:
920
+ * this.setFixedTimestep((ctx) => {
921
+ * this.applyInputs(ctx); // consume ONE input per client per step
922
+ * for (let i = 0; i < ctx.subSteps; i++) this.world.step(ctx.subDt);
923
+ * }, 30, { subSteps: 2 });
924
+ * }
925
+ * ```
926
+ */
927
+ public setFixedTimestep(step: FixedTimestepCallback, tickRate: number = Math.round(1000 / DEFAULT_SIMULATION_INTERVAL), opts?: { subSteps?: number }): void {
928
+ if (this._simulationInterval) { clearInterval(this._simulationInterval); }
929
+
930
+ const stepMs = 1000 / tickRate;
931
+ const stepSeconds = 1 / tickRate;
932
+ // Explicit option wins; else an earlier defineInput({ subSteps }) declaration.
933
+ const subSteps = validateSubSteps(opts?.subSteps, 'setFixedTimestep')
934
+ ?? this._inputController?.options.subSteps ?? 1;
935
+
936
+ // Single source of the fixed rate (and sub-step count): advertise both to
937
+ // predicting clients so N and dt can't drift between the two sides.
938
+ if (this._inputController) {
939
+ this._inputController.options.tickRate = tickRate;
940
+ this._inputController.options.subSteps = subSteps;
941
+ }
942
+
943
+ let cb = step;
944
+ if (this.onUncaughtException !== undefined) {
945
+ cb = wrapTryCatch(step, this.onUncaughtException.bind(this), TimestepException, 'setFixedTimestep');
946
+ }
947
+
948
+ let acc = 0;
949
+ let tick = 0;
950
+ // Reused per-step context — no per-step allocation in the hot loop.
951
+ // subDt = stepSeconds/subSteps: same expression as the client handle's
952
+ // subStepSeconds, so the per-sub-step dt is bit-identical on both sides.
953
+ const ctx = {
954
+ dt: stepSeconds, dtMs: stepMs, tick: 0,
955
+ subSteps, subDt: stepSeconds / subSteps, subDtMs: stepMs / subSteps,
956
+ };
957
+ const MAX_CATCHUP_STEPS = 5;
958
+
959
+ this._simulationInterval = setInterval(() => {
960
+ this.clock.tick();
961
+ acc += this.clock.deltaTime;
962
+
963
+ // Run a whole number of FIXED steps to consume the measured time.
964
+ let ran = 0;
965
+ while (acc >= stepMs && ran < MAX_CATCHUP_STEPS) {
966
+ acc -= stepMs;
967
+ ctx.tick = tick++;
968
+ cb(ctx);
969
+ ran++;
970
+ }
971
+ if (ran === MAX_CATCHUP_STEPS) { acc = 0; } // hitch: drop backlog, don't spiral
972
+ }, stepMs);
973
+ }
974
+
975
+ /** Server-side lag compensation, lazily created. @see allowRewindState */
976
+ #rewind?: Rewind;
977
+
978
+ /** @internal The rewind attachments' timeline mode (snapshot/reckon), or
979
+ * `undefined` when no rewind is configured. {@link RoomInput} reads it to
980
+ * derive the wire input stamp mode (snapshot → renderTime, reckon → reckonTime). */
981
+ _timelineMode(): { snapshot?: boolean; reckon?: boolean } | undefined {
982
+ return this.#rewind?.timelineMode();
983
+ }
984
+
985
+ /**
986
+ * Enable server-side lag compensation: returns a {@link Rewind} that records the
987
+ * position history of the entities you attach and automatically snapshots them
988
+ * on each broadcast (the patchRate cadence). Attach the collections to rewind,
989
+ * then read past positions (at a client's renderTime) in your hit tests.
990
+ *
991
+ * @example
992
+ * ```ts
993
+ * const rewind = this.allowRewindState({ maxRewindMs: 500 });
994
+ * rewind.attachAll(this.state.enemies, { fields: ["x", "y"] });
995
+ * // in a hit test — rewind to where the SHOOTER saw the world:
996
+ * const seen = rewind.lastSeenBy(shooterSessionId); // needs this.defineInput(...)
997
+ * const seenX = seen.value(enemy, "x"), seenY = seen.value(enemy, "y");
998
+ * ```
999
+ *
1000
+ * Per-client stamps auto-enable from the `attachAll` `mode` of the groups you
1001
+ * rewind — no `renderTime` flag. The default auto-record fires on each
1002
+ * broadcast, snapshotting exactly what the client receives — so the rewind
1003
+ * reproduces the client's interpolation and hits stay exact even when the
1004
+ * broadcast rate differs from the sim rate (`patchRate ≠ timestep`). Call
1005
+ * `rewind.record()` yourself during a tick to take over that cadence (you then
1006
+ * own correctness against your own broadcast rate).
1007
+ */
1008
+ public allowRewindState(opts?: RewindOptions): Rewind {
1009
+ this.#rewind = Rewind.get(this, opts);
1010
+ // Resolve `rewind.lastSeenBy(sid)` through the framework-owned input API.
1011
+ // Lazy: read at call time, so field-init order between `inputs` and `rewind`
1012
+ // doesn't matter. A missing input API fails loudly; a client that hasn't
1013
+ // stamped yet legitimately reads 0 → live fallback (NOT a config error).
1014
+ this.#rewind.bindRenderTime((sessionId) => {
1015
+ const api = this._inputController?.api;
1016
+ if (api === undefined) {
1017
+ throw new Error(
1018
+ "rewind.lastSeenBy() found no input API. Declare " +
1019
+ "`inputs = this.defineInput(YourInput)`, or use " +
1020
+ "rewind.at(time) with a render time you track yourself.",
1021
+ );
1022
+ }
1023
+ return api.get(sessionId).renderTime;
1024
+ });
1025
+ // Direct reckon-display stamp: mode:"reckon" groups read at exactly the
1026
+ // instant the client displayed them — immune to its RTT-estimation error.
1027
+ // RAW (not the accessor's resolved getter): _aim's midpoint fallback must
1028
+ // see 0 for unstamped clients.
1029
+ this.#rewind.bindReckonTime((sessionId) =>
1030
+ this._inputController?.rawReckonTime(sessionId) ?? 0,
1031
+ );
1032
+ // Anchor the reckon midpoint FALLBACK at the true processing instant (the
1033
+ // record timestamp is one tick stale by hit-test time — see bindNow).
1034
+ this.#rewind.bindNow(() => this.clock.elapsedTime);
1035
+ return this.#rewind;
1036
+ }
1037
+
656
1038
  /**
657
1039
  * @deprecated Use `.patchRate=` instead.
658
1040
  */
@@ -671,21 +1053,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
671
1053
  this._serializer = serializer;
672
1054
  }
673
1055
 
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
- }
1056
+ public async setMetadata(meta: ExtractRoomMetadata<T>, persist: boolean = true) {
1057
+ this._listing.metadata = meta;
689
1058
 
690
1059
  if (persist && this._internalState === RoomInternalState.CREATED) {
691
1060
  await matchMaker.driver.persist(this._listing);
@@ -735,7 +1104,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
735
1104
  *
736
1105
  * @example
737
1106
  * ```typescript
738
- * // Partial metadata update (merges with existing)
1107
+ * // Merging with existing metadata: spread `this.metadata` yourself.
1108
+ * // `metadata` is always REPLACED (not merged) by setMatchmaking()/setMetadata().
739
1109
  * await this.setMatchmaking({
740
1110
  * metadata: { ...this.metadata, round: this.metadata.round + 1 }
741
1111
  * });
@@ -894,12 +1264,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
894
1264
  ...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>
895
1265
  ) {
896
1266
  const [message, options] = args;
897
- if (options && options.afterNextPatch) {
898
- delete options.afterNextPatch;
899
- this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);
900
- return;
901
- }
902
-
903
1267
  this.broadcastMessageType(type, message, options);
904
1268
  }
905
1269
 
@@ -907,12 +1271,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
907
1271
  * Broadcast bytes (UInt8Arrays) to a particular room
908
1272
  */
909
1273
  public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {
910
- if (options && options.afterNextPatch) {
911
- delete options.afterNextPatch;
912
- this._afterNextPatchQueue.push(['broadcastBytes', arguments]);
913
- return;
914
- }
915
-
916
1274
  this.broadcastMessageType(type as string, message, options);
917
1275
  }
918
1276
 
@@ -932,10 +1290,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
932
1290
  return false;
933
1291
  }
934
1292
 
935
- const hasChanges = this._serializer.applyPatches(this.clients, this.state);
1293
+ // When `defineInput()` was called, hand the serializer a fresh `sNow`
1294
+ // each tick. The per-client `lastTReceived` is read off the client at
1295
+ // encode time inside `applyPatches`.
1296
+ const sNow = this.clock.elapsedTime;
1297
+ const hasChanges = this._serializer.applyPatches(
1298
+ this.clients,
1299
+ this.state,
1300
+ this._inputController !== undefined ? { sNow } : undefined,
1301
+ );
1302
+
1303
+ // Deliver any per-client `afterNextPatch` frames as standalone frames right
1304
+ // after the patch (never coalesced into it). Iterates only the clients that
1305
+ // staged frames this cycle (`#pendingFrameClients`), never the full list.
1306
+ this._flushPendingClientFrames();
936
1307
 
937
- // broadcast messages enqueued for "after patch"
938
- this._dequeueAfterPatchMessages();
1308
+ // After-patch broadcasts ride here as shared frames (one buffer → N sends).
1309
+ this._flushAfterPatchBroadcasts();
1310
+
1311
+ // Lag-comp: record the snapshot the client just received, on the broadcast
1312
+ // cadence and stamped with the SAME `sNow` the frame carries — so valueAt()
1313
+ // reproduces the client's interpolation over the IDENTICAL pair (exact hits
1314
+ // at any patchRate). Idempotent per `sNow`: a manual record() this tick wins,
1315
+ // and a patchRate faster than the sim simply dedups back to per-tick.
1316
+ const rw = this.#rewind;
1317
+ if (rw !== undefined && rw.lastRecordedAt !== sNow) {
1318
+ rw.record(sNow, this.#_patchRate || undefined);
1319
+ }
939
1320
 
940
1321
  return hasChanges;
941
1322
  }
@@ -970,43 +1351,19 @@ export class Room<T extends RoomOptions = RoomOptions> {
970
1351
  );
971
1352
  public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
972
1353
  messageType: string | number,
973
- callback: (client: C, message: T) => void,
1354
+ callback: (client: C, message: T, ctx: MessageContext) => unknown,
974
1355
  );
975
1356
  public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
976
1357
  messageType: string | number,
977
1358
  validationSchema: StandardSchemaV1<T>,
978
- callback: (client: C, message: T) => void,
1359
+ callback: (client: C, message: T, ctx: MessageContext) => unknown,
979
1360
  );
980
1361
  public onMessage<T = any>(
981
1362
  _messageType: '*' | string | number,
982
1363
  _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
983
1364
  _callback?: (...args: any[]) => void,
984
1365
  ) {
985
- const messageType = _messageType.toString();
986
-
987
- const validationSchema = (typeof _callback === 'function')
988
- ? _validationSchema as StandardSchemaV1<T>
989
- : undefined;
990
-
991
- const callback = (validationSchema === undefined)
992
- ? _validationSchema as (...args: any[]) => void
993
- : _callback;
994
-
995
- const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)
996
- ? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)
997
- : callback);
998
-
999
- if (validationSchema !== undefined) {
1000
- this.onMessageValidators[messageType] = validationSchema;
1001
- }
1002
-
1003
- // returns a method to unbind the callback
1004
- return () => {
1005
- removeListener();
1006
- if (this.onMessageEvents.events[messageType].length === 0) {
1007
- delete this.onMessageValidators[messageType];
1008
- }
1009
- };
1366
+ return this.#_messages.on(_messageType, _validationSchema as any, _callback);
1010
1367
  }
1011
1368
 
1012
1369
  public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
@@ -1025,20 +1382,117 @@ export class Room<T extends RoomOptions = RoomOptions> {
1025
1382
  _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
1026
1383
  _callback?: (...args: any[]) => void,
1027
1384
  ) {
1028
- const messageType = `_$b${_messageType}`;
1385
+ return this.#_messages.on(`_$b${_messageType}`, _validationSchema as any, _callback);
1386
+ }
1029
1387
 
1030
- const validationSchema = (typeof _callback === 'function')
1031
- ? _validationSchema as StandardSchemaV1<T>
1032
- : undefined;
1388
+ // ---------------------------------------------------------------------------
1389
+ // Operator API — used by @colyseus/admin (and monitor in due course)
1390
+ // through `remoteRoomCall(roomId, methodName)`. Marked `@internal` because
1391
+ // they're framework-tooling primitives, not part of the game-code surface.
1392
+ // ---------------------------------------------------------------------------
1033
1393
 
1034
- const callback = (validationSchema === undefined)
1035
- ? _validationSchema as (...args: any[]) => void
1036
- : _callback;
1394
+ /**
1395
+ * Snapshot the room's live state for an inspector / admin UI. Includes:
1396
+ *
1397
+ * roomId, name, maxClients, locked, elapsedTime (ms),
1398
+ * metadata, clients (sessionId + per-client elapsed + userId when set),
1399
+ * state (the schema/json the SDK would see),
1400
+ * stateSize (bytes of the encoded full state, or 0 when no serializer).
1401
+ *
1402
+ * The payload is intentionally plain JSON — `remoteRoomCall` serializes
1403
+ * the return value across process boundaries.
1404
+ *
1405
+ * @internal Operator-only. Game code should not call this.
1406
+ */
1407
+ public getInspectorView(): {
1408
+ roomId: string;
1409
+ name: string;
1410
+ clients: number;
1411
+ maxClients: number;
1412
+ locked: boolean;
1413
+ elapsedTime: number;
1414
+ metadata: any;
1415
+ clientList: Array<{
1416
+ sessionId: string;
1417
+ userId: string | null;
1418
+ userEmail: string | null;
1419
+ elapsedTime: number;
1420
+ }>;
1421
+ state: any;
1422
+ stateSize: number;
1423
+ } {
1424
+ const elapsed = this.clock.elapsedTime;
1425
+ return {
1426
+ roomId: this.roomId,
1427
+ name: this.roomName,
1428
+ clients: this.clients.length,
1429
+ maxClients: this.maxClients,
1430
+ locked: this.#_locked,
1431
+ elapsedTime: elapsed,
1432
+ metadata: this.metadata ?? null,
1433
+ // Cast through `unknown`: `ExtractRoomClient<T>` vs `Client & ClientPrivate`
1434
+ // don't structurally overlap (the user's `client` generic may carry a
1435
+ // narrower userData/auth shape), but the runtime objects we walk here
1436
+ // always have the private join-time field. The narrow per-property
1437
+ // `(c as any)` reads below keep the cast scoped.
1438
+ //
1439
+ // userId / userEmail read straight off `client.auth` — the JWT
1440
+ // payload @colyseus/auth's default onAuth decodes carries both
1441
+ // when the user has them on file. Saves the admin a per-client
1442
+ // database round-trip; falls back to null when the client signed
1443
+ // in anonymously (or with a custom onAuth that returns a shape
1444
+ // without those fields).
1445
+ clientList: (this.clients as unknown as ReadonlyArray<Client & ClientPrivate>).map((c) => {
1446
+ const auth = (c as any).auth;
1447
+ return {
1448
+ sessionId: c.sessionId,
1449
+ userId: ((c as any).userId ?? auth?.id) ?? null,
1450
+ userEmail: auth?.email ?? null,
1451
+ elapsedTime: elapsed - ((c as any)._joinedAt ?? elapsed),
1452
+ };
1453
+ }),
1454
+ state: this.state ?? null,
1455
+ stateSize: this.#_inspectorStateSize(),
1456
+ };
1457
+ }
1037
1458
 
1038
- if (validationSchema !== undefined) {
1039
- return this.onMessage(messageType, validationSchema as any, callback as any);
1040
- } else {
1041
- return this.onMessage(messageType, callback as any);
1459
+ /**
1460
+ * Force-disconnect a single client by sessionId. No-op when the client
1461
+ * isn't connected (idempotent — the caller doesn't need to race-check).
1462
+ *
1463
+ * @internal Operator-only. Game code disconnects clients by calling
1464
+ * `.leave()` on the Client object directly.
1465
+ */
1466
+ public kickClient(sessionId: string, closeCode: number = CloseCode.CONSENTED, reason?: string): void {
1467
+ // Same `unknown` indirection as in `getInspectorView` above —
1468
+ // ExtractRoomClient<T> doesn't structurally include ClientPrivate.
1469
+ for (const client of this.clients as unknown as ReadonlyArray<Client & ClientPrivate>) {
1470
+ if (client.sessionId === sessionId) {
1471
+ this.#_forciblyCloseClient(client as ExtractRoomClient<T> & ClientPrivate, closeCode, reason);
1472
+ return;
1473
+ }
1474
+ }
1475
+ }
1476
+
1477
+ /**
1478
+ * Best-effort byte size of the current full state. Falls back to `0`
1479
+ * when the room has no serializer or the serializer can't produce a
1480
+ * payload (raw rooms, very-early-onCreate, etc.). The serializer
1481
+ * detection mirrors `@colyseus/monitor`'s — we read whichever buffer
1482
+ * is available across schema v2 and v3.
1483
+ */
1484
+ #_inspectorStateSize(): number {
1485
+ const ser = this._serializer as any;
1486
+ if (!ser) { return 0; }
1487
+ const hasState = ser.encoder || ser.state;
1488
+ if (!hasState) { return 0; }
1489
+ try {
1490
+ const full = ser.getFullState?.();
1491
+ if (!full) { return 0; }
1492
+ // Buffer / Uint8Array have `byteLength`; raw arrays have `length`.
1493
+ return (full as any).byteLength ?? (full as any).length ?? 0;
1494
+ } catch {
1495
+ return 0;
1042
1496
  }
1043
1497
  }
1044
1498
 
@@ -1103,6 +1557,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
1103
1557
  // (each new reconnection receives a new reconnection token)
1104
1558
  client.reconnectionToken = generateId();
1105
1559
 
1560
+ // Allocate per-client input state early so onJoin can read inputs.get(sid).latest.
1561
+ this._inputController?.allocate(client);
1562
+
1106
1563
  if (this._reservedSeatTimeouts[sessionId]) {
1107
1564
  clearTimeout(this._reservedSeatTimeouts[sessionId]);
1108
1565
  delete this._reservedSeatTimeouts[sessionId];
@@ -1121,7 +1578,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1121
1578
  if (
1122
1579
  this._reservedSeats[sessionId] === undefined &&
1123
1580
  connectionOptions?.reconnectionToken &&
1124
- this.clients.getById(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken
1581
+ this.clients.get(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken
1125
1582
  ) {
1126
1583
  debugMatchMaking('attempting to reconnect client with a stale previous connection - sessionId: \'%s\', roomId: \'%s\'', client.sessionId, this.roomId);
1127
1584
  this._reconnectionAttempts[connectionOptions.reconnectionToken] = new Deferred();
@@ -1160,8 +1617,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
1160
1617
  this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed"
1161
1618
  debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId);
1162
1619
 
1163
- // share "after next patch queue" reference with every client.
1164
- client._afterNextPatchQueue = this._afterNextPatchQueue;
1620
+ // Share the after-patch flush list so a client can announce staged frames
1621
+ // without the flush scanning every client. `_pendingFrames` are delivered as
1622
+ // standalone frames right after the patch by `_flushPendingClientFrames`.
1623
+ (client as Client & ClientPrivate)._pendingFrameClients = this.#pendingFrameClients;
1165
1624
 
1166
1625
  // add temporary callback to keep track of disconnections during `onJoin`.
1167
1626
  client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;
@@ -1171,6 +1630,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
1171
1630
  const reconnectionToken = connectionOptions?.reconnectionToken;
1172
1631
  if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
1173
1632
  this.clients.push(client);
1633
+ // After push (no leak on a failed join), before onReconnect; overwrites
1634
+ // the dropped session's stale entry.
1635
+ this._inputController?.register(sessionId, client);
1174
1636
 
1175
1637
  //
1176
1638
  // await for reconnection:
@@ -1236,6 +1698,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
1236
1698
  }
1237
1699
 
1238
1700
  this.clients.push(client);
1701
+ // After push (no leak on a failed join), before onJoin (resolves inputs.get there).
1702
+ this._inputController?.register(sessionId, client);
1239
1703
 
1240
1704
  //
1241
1705
  // Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it
@@ -1289,6 +1753,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
1289
1753
  // allow client to send messages after onJoin has succeeded.
1290
1754
  client.ref.on('message', this._onMessage.bind(this, client));
1291
1755
 
1756
+ // NOT gated by skipHandshake: that flag means "client already has the STATE
1757
+ // schema", but these carry input config the client can't derive locally
1758
+ // (re-parsing on reconnect is idempotent).
1759
+ const extraSections = this._inputController?.handshakeSections();
1760
+
1292
1761
  // confirm room id that matches the room name requested to join
1293
1762
  client.raw(getMessageBytes[Protocol.JOIN_ROOM](
1294
1763
  client.reconnectionToken,
@@ -1300,6 +1769,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1300
1769
  (connectionOptions?.skipHandshake)
1301
1770
  ? undefined
1302
1771
  : this._serializer.handshake && this._serializer.handshake(),
1772
+ extraSections,
1303
1773
  ));
1304
1774
  }
1305
1775
  }
@@ -1404,7 +1874,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1404
1874
  // switching Wi-Fi), as the original connection may still be open while a
1405
1875
  // new reconnection attempt is being made.
1406
1876
  //
1407
- if (this._reconnectionAttempts[reconnectionToken]) {
1877
+ if (this._reconnectionAttempts[reconnectionToken] !== undefined) {
1408
1878
  debugMatchMaking('resolving reconnection attempt for client - sessionId: \'%s\', roomId: \'%s\'', sessionId, this.roomId);
1409
1879
  this._reconnectionAttempts[reconnectionToken].resolve(true);
1410
1880
  }
@@ -1438,39 +1908,67 @@ export class Room<T extends RoomOptions = RoomOptions> {
1438
1908
  : [options.except]
1439
1909
  : undefined;
1440
1910
 
1911
+ // `afterNextPatch` in a NON-timed room: the patch is a SHARED buffer, so
1912
+ // coalescing would force N per-client copies. Keep this one shared buffer and
1913
+ // ride it right after the patch (see #afterPatchBroadcasts). `except` is
1914
+ // captured now; recipients resolve at drain time.
1915
+ if (options.afterNextPatch && this._inputController === undefined) {
1916
+ this.#afterPatchBroadcasts.push({ bytes: encodedMessage, except });
1917
+ return;
1918
+ }
1919
+
1920
+ // Otherwise one loop: a TIMED-room `afterNextPatch` broadcast stages onto each
1921
+ // recipient's `_pendingFrames` (flushed right after the patch); an immediate
1922
+ // broadcast sends now.
1923
+ const sendOpts = options.afterNextPatch ? AFTER_PATCH_OPTS : undefined;
1441
1924
  let numClients = this.clients.length;
1442
1925
  while (numClients--) {
1443
1926
  const client = this.clients[numClients];
1444
-
1445
1927
  if (!except || !except.includes(client)) {
1446
- client.enqueueRaw(encodedMessage);
1928
+ client.enqueueRaw(encodedMessage, sendOpts);
1447
1929
  }
1448
1930
  }
1449
1931
  }
1450
1932
 
1451
- private sendFullState(client: Client): void {
1452
- client.raw(this._serializer.getFullState(client));
1933
+ /** Drain `#afterPatchBroadcasts` right after the patch — each as ONE shared
1934
+ * buffer fanned out to its (non-excepted) recipients. `enqueueRaw` handles
1935
+ * JOINED (send now) vs still-joining (buffer until JOIN) per client. */
1936
+ private _flushAfterPatchBroadcasts() {
1937
+ const queued = this.#afterPatchBroadcasts;
1938
+ if (queued.length === 0) { return; }
1939
+ for (let i = 0; i < queued.length; i++) {
1940
+ const { bytes, except } = queued[i];
1941
+ let numClients = this.clients.length;
1942
+ while (numClients--) {
1943
+ const client = this.clients[numClients];
1944
+ if (!except || !except.includes(client)) { client.enqueueRaw(bytes); }
1945
+ }
1946
+ }
1947
+ queued.length = 0;
1453
1948
  }
1454
1949
 
1455
- private _dequeueAfterPatchMessages() {
1456
- const length = this._afterNextPatchQueue.length;
1457
-
1458
- if (length > 0) {
1459
- for (let i = 0; i < length; i++) {
1460
- const [target, args] = this._afterNextPatchQueue[i];
1461
-
1462
- if (target === "broadcast") {
1463
- this.broadcast.apply(this, args as any);
1950
+ private sendFullState(client: Client): void {
1951
+ client.raw(this._serializer.getFullState(
1952
+ client,
1953
+ this._inputController !== undefined ? { sNow: this.clock.elapsedTime } : undefined,
1954
+ ));
1955
+ }
1464
1956
 
1465
- } else {
1466
- (target as Client).raw.apply(target, args as any);
1467
- }
1957
+ /** Send each client's staged `afterNextPatch` frames as standalone frames right
1958
+ * after the patch, then reset the tracker. Iterates only the clients that staged
1959
+ * frames this cycle (`#pendingFrameClients`), never the full client list; empty
1960
+ * (→ O(1)) on a tick where nothing was staged. */
1961
+ private _flushPendingClientFrames() {
1962
+ const dirty = this.#pendingFrameClients;
1963
+ if (dirty.length === 0) { return; }
1964
+ for (let i = 0; i < dirty.length; i++) {
1965
+ const frames = dirty[i]._pendingFrames;
1966
+ if (frames !== undefined && frames.length > 0) {
1967
+ for (let j = 0; j < frames.length; j++) { dirty[i].raw(frames[j]); }
1968
+ frames.length = 0;
1468
1969
  }
1469
-
1470
- // new messages may have been added in the meantime,
1471
- // let's splice the ones that have been processed
1472
- this._afterNextPatchQueue.splice(0, length);
1473
1970
  }
1971
+ dirty.length = 0;
1474
1972
  }
1475
1973
 
1476
1974
  private async _reserveSeat(
@@ -1517,6 +2015,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
1517
2015
  delete this._reconnections[devModeReconnectionToken];
1518
2016
  delete this._reservedSeats[sessionId];
1519
2017
  delete this._reservedSeatTimeouts[sessionId];
2018
+ // devMode reconnection bypasses #_onAfterLeave — drop the input accessor here.
2019
+ this._inputController?.release(sessionId);
1520
2020
 
1521
2021
  if (!allowReconnection) {
1522
2022
  await this.#_decrementClientCount();
@@ -1584,7 +2084,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1584
2084
  }
1585
2085
 
1586
2086
  if (this._autoDisposeTimeout) {
1587
- clearInterval(this._autoDisposeTimeout);
2087
+ clearTimeout(this._autoDisposeTimeout);
1588
2088
  this._autoDisposeTimeout = undefined;
1589
2089
  }
1590
2090
 
@@ -1592,6 +2092,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
1592
2092
  this.clock.clear();
1593
2093
  this.clock.stop();
1594
2094
 
2095
+ // drop any input accessors still held for in-flight reconnections
2096
+ this._inputController?.dispose();
2097
+
1595
2098
  return await (userReturnData || Promise.resolve());
1596
2099
  }
1597
2100
 
@@ -1615,71 +2118,25 @@ export class Room<T extends RoomOptions = RoomOptions> {
1615
2118
  }
1616
2119
 
1617
2120
  const it: Iterator = { offset: 1 };
1618
- const code = buffer[0];
2121
+ // Strip modifier bits (e.g. ProtocolModifier.TIMED on a render-time input)
2122
+ // before dispatch; existing client→server codes set no modifiers.
2123
+ const code = buffer[0] & PROTOCOL_CODE_MASK;
2124
+ const modifiers = buffer[0] & PROTOCOL_MODIFIER_MASK;
1619
2125
 
1620
2126
  if (code === Protocol.ROOM_DATA) {
1621
- const messageType = (decode.stringCheck(buffer, it))
1622
- ? decode.string(buffer, it)
1623
- : decode.number(buffer, it);
2127
+ this.#_messages.onData(client, buffer, it);
1624
2128
 
1625
- let message;
1626
- try {
1627
- message = (buffer.byteLength > it.offset)
1628
- ? unpack(buffer.subarray(it.offset, buffer.byteLength))
1629
- : undefined;
1630
- debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
1631
-
1632
- // custom message validation
1633
- if (this.onMessageValidators[messageType] !== undefined) {
1634
- message = standardValidate(this.onMessageValidators[messageType], message);
1635
- }
1636
-
1637
- } catch (e: any) {
1638
- debugAndPrintError(e);
1639
- client.leave(CloseCode.WITH_ERROR);
1640
- return;
1641
- }
1642
-
1643
- if (this.onMessageEvents.events[messageType]) {
1644
- this.onMessageEvents.emit(messageType as string, client, message);
1645
-
1646
- } else if (this.onMessageEvents.events['*']) {
1647
- this.onMessageEvents.emit('*', client, messageType, message);
1648
-
1649
- } else {
1650
- this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
1651
- }
2129
+ } else if (code === Protocol.ROOM_REQUEST) {
2130
+ this.#_messages.onRequest(client, buffer, it);
1652
2131
 
1653
2132
  } else if (code === Protocol.ROOM_DATA_BYTES) {
1654
- const messageType = (decode.stringCheck(buffer, it))
1655
- ? decode.string(buffer, it)
1656
- : decode.number(buffer, it);
1657
-
1658
- let message: any = buffer.subarray(it.offset, buffer.byteLength);
1659
- debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
2133
+ this.#_messages.onDataBytes(client, buffer, it);
1660
2134
 
1661
- const bytesMessageType = `_$b${messageType}`;
2135
+ } else if (code === Protocol.ROOM_INPUT_RELIABLE) {
2136
+ this._inputController?.decodeReliable(client, buffer, it, modifiers);
1662
2137
 
1663
- // custom message validation
1664
- try {
1665
- if (this.onMessageValidators[bytesMessageType] !== undefined) {
1666
- message = standardValidate(this.onMessageValidators[bytesMessageType], message);
1667
- }
1668
- } catch (e: any) {
1669
- debugAndPrintError(e);
1670
- client.leave(CloseCode.WITH_ERROR);
1671
- return;
1672
- }
1673
-
1674
- if (this.onMessageEvents.events[bytesMessageType]) {
1675
- this.onMessageEvents.emit(bytesMessageType, client, message);
1676
-
1677
- } else if (this.onMessageEvents.events['*']) {
1678
- this.onMessageEvents.emit('*', client, messageType, message);
1679
-
1680
- } else {
1681
- this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
1682
- }
2138
+ } else if (code === Protocol.ROOM_INPUT_UNRELIABLE) {
2139
+ this._inputController?.decodeUnreliable(client, buffer, modifiers);
1683
2140
 
1684
2141
  } else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
1685
2142
  // join room has been acknowledged by the client
@@ -1705,7 +2162,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1705
2162
  }
1706
2163
  }
1707
2164
 
1708
- #_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number) {
2165
+ #_forciblyCloseClient(client: ExtractRoomClient<T> & ClientPrivate, closeCode: number, reason?: string) {
1709
2166
  // stop receiving messages from this client
1710
2167
  client.ref.removeAllListeners('message');
1711
2168
 
@@ -1713,7 +2170,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1713
2170
  client.ref.removeListener('close', client.ref['onleave']);
1714
2171
 
1715
2172
  // only effectively close connection when "onLeave" is fulfilled
1716
- this._onLeave(client, closeCode).then(() => client.leave(closeCode));
2173
+ this._onLeave(client, closeCode).then(() => (client as any).leave(closeCode, reason));
1717
2174
  }
1718
2175
 
1719
2176
  private async _onLeave(client: ExtractRoomClient<T>, code?: number): Promise<any> {
@@ -1729,6 +2186,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
1729
2186
  return;
1730
2187
  }
1731
2188
 
2189
+ // Freeze the seat: a held (reconnecting) session idles from the first tick
2190
+ // instead of replaying last-known moves.
2191
+ this._inputController?.freeze(client as unknown as ClientPrivate);
2192
+
1732
2193
  if (method) {
1733
2194
  debugMatchMaking(`${method.name}, sessionId: \'%s\' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
1734
2195
 
@@ -1769,9 +2230,11 @@ export class Room<T extends RoomOptions = RoomOptions> {
1769
2230
 
1770
2231
  // trigger 'leave' only if seat reservation has been fully consumed
1771
2232
  if (this._reservedSeats[client.sessionId] === undefined) {
2233
+ // Session fully gone — drop its accessor. No identity check needed: a
2234
+ // successful reconnect skips this finalizer, so the entry is always this client's.
2235
+ this._inputController?.release(client.sessionId);
1772
2236
  this._events.emit('leave', client, willDispose);
1773
2237
  }
1774
-
1775
2238
  }
1776
2239
 
1777
2240
  async #_incrementClientCount() {
@@ -1856,53 +2319,4 @@ export class Room<T extends RoomOptions = RoomOptions> {
1856
2319
  }
1857
2320
  }
1858
2321
 
1859
- }
1860
-
1861
- /**
1862
- * (WIP) Alternative, method-based room definition.
1863
- * We should be able to define
1864
- */
1865
-
1866
- type RoomLifecycleMethods =
1867
- | 'messages'
1868
- | 'onCreate'
1869
- | 'onJoin'
1870
- | 'onLeave'
1871
- | 'onDispose'
1872
- | 'onCacheRoom'
1873
- | 'onRestoreRoom'
1874
- | 'onDrop'
1875
- | 'onReconnect'
1876
- | 'onUncaughtException'
1877
- | 'onAuth'
1878
- | 'onBeforeShutdown'
1879
- | 'onBeforePatch';
1880
-
1881
- type DefineRoomOptions<T extends RoomOptions = RoomOptions> =
1882
- Partial<Pick<Room<T>, RoomLifecycleMethods>> &
1883
- { state?: ExtractRoomState<T> | (() => ExtractRoomState<T>); } &
1884
- ThisType<Exclude<Room<T>, RoomLifecycleMethods>> &
1885
- ThisType<Room<T>>
1886
- ;
1887
-
1888
- export function room<T>(options: DefineRoomOptions<T>) {
1889
- class _ extends Room<T> {
1890
- messages = options.messages;
1891
-
1892
- constructor() {
1893
- super();
1894
- if (options.state && typeof options.state === 'function') {
1895
- this.state = options.state();
1896
- }
1897
- }
1898
- }
1899
-
1900
- // Copy all methods to the prototype
1901
- for (const key in options) {
1902
- if (typeof options[key] === 'function') {
1903
- _.prototype[key] = options[key];
1904
- }
1905
- }
1906
-
1907
- return _ as typeof Room<T>;
1908
2322
  }