@colyseus/core 0.18.0 → 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 (125) hide show
  1. package/build/MatchMaker.cjs +3 -7
  2. package/build/MatchMaker.cjs.map +2 -2
  3. package/build/MatchMaker.mjs +4 -8
  4. package/build/MatchMaker.mjs.map +2 -2
  5. package/build/Protocol.cjs +3 -3
  6. package/build/Protocol.cjs.map +1 -1
  7. package/build/Protocol.d.ts +3 -3
  8. package/build/Protocol.mjs +3 -3
  9. package/build/Protocol.mjs.map +1 -1
  10. package/build/Rewind.cjs +385 -0
  11. package/build/Rewind.cjs.map +7 -0
  12. package/build/Rewind.d.ts +291 -0
  13. package/build/Rewind.mjs +359 -0
  14. package/build/Rewind.mjs.map +7 -0
  15. package/build/Room.cjs +280 -281
  16. package/build/Room.cjs.map +3 -3
  17. package/build/Room.d.ts +185 -81
  18. package/build/Room.mjs +286 -286
  19. package/build/Room.mjs.map +2 -2
  20. package/build/RoomMessages.cjs +265 -0
  21. package/build/RoomMessages.cjs.map +7 -0
  22. package/build/RoomMessages.d.ts +49 -0
  23. package/build/RoomMessages.mjs +240 -0
  24. package/build/RoomMessages.mjs.map +7 -0
  25. package/build/Server.cjs +88 -31
  26. package/build/Server.cjs.map +2 -2
  27. package/build/Server.d.ts +36 -0
  28. package/build/Server.mjs +88 -31
  29. package/build/Server.mjs.map +2 -2
  30. package/build/Transport.cjs +26 -0
  31. package/build/Transport.cjs.map +2 -2
  32. package/build/Transport.d.ts +69 -2
  33. package/build/Transport.mjs +25 -0
  34. package/build/Transport.mjs.map +2 -2
  35. package/build/errors/RoomExceptions.cjs +7 -4
  36. package/build/errors/RoomExceptions.cjs.map +2 -2
  37. package/build/errors/RoomExceptions.d.ts +15 -3
  38. package/build/errors/RoomExceptions.mjs +5 -3
  39. package/build/errors/RoomExceptions.mjs.map +2 -2
  40. package/build/index.cjs +19 -2
  41. package/build/index.cjs.map +2 -2
  42. package/build/index.d.ts +7 -4
  43. package/build/index.mjs +17 -4
  44. package/build/index.mjs.map +2 -2
  45. package/build/input/InputBuffer.cjs +439 -16
  46. package/build/input/InputBuffer.cjs.map +2 -2
  47. package/build/input/InputBuffer.d.ts +186 -96
  48. package/build/input/InputBuffer.mjs +436 -15
  49. package/build/input/InputBuffer.mjs.map +2 -2
  50. package/build/input/RoomInput.cjs +306 -0
  51. package/build/input/RoomInput.cjs.map +7 -0
  52. package/build/input/RoomInput.d.ts +91 -0
  53. package/build/input/RoomInput.mjs +287 -0
  54. package/build/input/RoomInput.mjs.map +7 -0
  55. package/build/input/types.cjs +18 -0
  56. package/build/input/types.cjs.map +7 -0
  57. package/build/input/types.d.ts +476 -0
  58. package/build/input/types.mjs +0 -0
  59. package/build/input/types.mjs.map +7 -0
  60. package/build/rooms/QueueRoom.cjs +1 -1
  61. package/build/rooms/QueueRoom.cjs.map +2 -2
  62. package/build/rooms/QueueRoom.mjs +1 -1
  63. package/build/rooms/QueueRoom.mjs.map +2 -2
  64. package/build/router/default_routes.cjs +4 -1
  65. package/build/router/default_routes.cjs.map +2 -2
  66. package/build/router/default_routes.mjs +4 -1
  67. package/build/router/default_routes.mjs.map +2 -2
  68. package/build/router/index.cjs +6 -1
  69. package/build/router/index.cjs.map +2 -2
  70. package/build/router/index.mjs +6 -1
  71. package/build/router/index.mjs.map +2 -2
  72. package/build/router/node.cjs +37 -2
  73. package/build/router/node.cjs.map +2 -2
  74. package/build/router/node.d.ts +10 -0
  75. package/build/router/node.mjs +35 -1
  76. package/build/router/node.mjs.map +2 -2
  77. package/build/serializer/NoneSerializer.cjs +2 -2
  78. package/build/serializer/NoneSerializer.cjs.map +2 -2
  79. package/build/serializer/NoneSerializer.d.ts +3 -3
  80. package/build/serializer/NoneSerializer.mjs +2 -2
  81. package/build/serializer/NoneSerializer.mjs.map +2 -2
  82. package/build/serializer/SchemaSerializer.cjs +44 -14
  83. package/build/serializer/SchemaSerializer.cjs.map +2 -2
  84. package/build/serializer/SchemaSerializer.d.ts +3 -3
  85. package/build/serializer/SchemaSerializer.mjs +46 -16
  86. package/build/serializer/SchemaSerializer.mjs.map +2 -2
  87. package/build/serializer/Serializer.cjs.map +1 -1
  88. package/build/serializer/Serializer.d.ts +12 -2
  89. package/build/utils/DevMode.cjs +14 -14
  90. package/build/utils/DevMode.cjs.map +2 -2
  91. package/build/utils/DevMode.d.ts +0 -2
  92. package/build/utils/DevMode.mjs +14 -12
  93. package/build/utils/DevMode.mjs.map +2 -2
  94. package/build/utils/Env.cjs +8 -4
  95. package/build/utils/Env.cjs.map +3 -3
  96. package/build/utils/Env.mjs +8 -4
  97. package/build/utils/Env.mjs.map +2 -2
  98. package/build/utils/Utils.cjs +3 -0
  99. package/build/utils/Utils.cjs.map +2 -2
  100. package/build/utils/Utils.d.ts +8 -0
  101. package/build/utils/Utils.mjs +2 -0
  102. package/build/utils/Utils.mjs.map +2 -2
  103. package/package.json +11 -11
  104. package/src/MatchMaker.ts +7 -9
  105. package/src/Protocol.ts +3 -3
  106. package/src/Rewind.ts +572 -0
  107. package/src/Room.ts +409 -448
  108. package/src/RoomMessages.ts +342 -0
  109. package/src/Server.ts +112 -38
  110. package/src/Transport.ts +97 -2
  111. package/src/errors/RoomExceptions.ts +18 -4
  112. package/src/index.ts +13 -2
  113. package/src/input/InputBuffer.ts +464 -107
  114. package/src/input/RoomInput.ts +337 -0
  115. package/src/input/types.ts +503 -0
  116. package/src/rooms/QueueRoom.ts +1 -1
  117. package/src/router/default_routes.ts +6 -1
  118. package/src/router/index.ts +6 -1
  119. package/src/router/node.ts +44 -0
  120. package/src/serializer/NoneSerializer.ts +3 -3
  121. package/src/serializer/SchemaSerializer.ts +110 -19
  122. package/src/serializer/Serializer.ts +13 -2
  123. package/src/utils/DevMode.ts +18 -13
  124. package/src/utils/Env.ts +12 -4
  125. package/src/utils/Utils.ts +9 -0
package/src/Room.ts CHANGED
@@ -1,15 +1,11 @@
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';
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';
6
8
 
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>();
13
9
  import { ClockTimer as Clock } from '@colyseus/timer';
14
10
 
15
11
  import { EventEmitter } from 'events';
@@ -24,26 +20,26 @@ import { SchemaSerializer } from './serializer/SchemaSerializer.ts';
24
20
 
25
21
  import { getMessageBytes } from './Protocol.ts';
26
22
  import { type Type, Deferred, generateId, wrapTryCatch } from './utils/Utils.ts';
27
- import { createNanoEvents } from './utils/nanoevents.ts';
28
23
  import { isDevMode } from './utils/DevMode.ts';
29
24
 
30
25
  import { debugAndPrintError, debugMatchMaking, debugMessage } from './Debug.ts';
31
26
  import { ServerError } from './errors/ServerError.ts';
32
27
  import { ClientState, type AuthContext, type Client, type ClientPrivate, ClientArray, type ISendOptions, type MessageArgs } from './Transport.ts';
33
- 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';
34
29
 
35
- import { standardValidate, type StandardSchemaV1 } from './utils/StandardSchema.ts';
30
+ import { type StandardSchemaV1 } from './utils/StandardSchema.ts';
36
31
  import * as matchMaker from './MatchMaker.ts';
37
32
 
38
33
  import {
39
34
  CloseCode,
40
35
  ErrorCode,
41
- HandshakeSection,
42
36
  Protocol,
43
- ResponseStatus,
37
+ PROTOCOL_CODE_MASK,
38
+ PROTOCOL_MODIFIER_MASK,
44
39
  type MessageHandlerWithFormat as SharedMessageHandlerWithFormat,
45
40
  type MessageHandler as SharedMessageHandler,
46
41
  type Messages as SharedMessages,
42
+ type MessageContext,
47
43
  } from '@colyseus/shared-types';
48
44
 
49
45
  import {
@@ -62,23 +58,49 @@ const DEFAULT_PATCH_RATE = 1000 / 20; // 20fps (50ms)
62
58
  const DEFAULT_SIMULATION_INTERVAL = 1000 / 60; // 60fps (16.66ms)
63
59
  const noneSerializer = new NoneSerializer();
64
60
 
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
- }
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 });
77
64
 
78
65
  export const DEFAULT_SEAT_RESERVATION_TIME = Number(process.env.COLYSEUS_SEAT_RESERVATION_TIME || 15);
79
66
 
80
67
  export type SimulationCallback = (deltaTime: number) => void;
81
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
+
82
104
  export interface RoomOptions {
83
105
  state?: object;
84
106
  metadata?: any;
@@ -134,7 +156,7 @@ export type Messages<This extends Room> = SharedMessages<This, Client>;
134
156
  */
135
157
  export function validate<T extends StandardSchemaV1, This = any>(
136
158
  format: T,
137
- handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>) => void
159
+ handler: (this: This, client: Client, message: StandardSchemaV1.InferOutput<T>, ctx: MessageContext) => unknown
138
160
  ): MessageHandlerWithFormat<T, This> {
139
161
  return { format, handler };
140
162
  }
@@ -301,6 +323,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
301
323
  private _reconnectionAttempts: { [reconnectionToken: string]: Deferred } = {};
302
324
 
303
325
  public messages?: Messages<any>;
326
+ /** @internal Message-routing layer: handler registry + user-message decode/dispatch. */
327
+ #_messages = new RoomMessages(this);
304
328
 
305
329
  /**
306
330
  * Room plugins, keyed by an operator-chosen handle. Each plugin
@@ -343,27 +367,24 @@ export class Room<T extends RoomOptions = RoomOptions> {
343
367
  */
344
368
  static __pluginLayout?: PluginLayout | null;
345
369
 
346
- private onMessageEvents = createNanoEvents();
347
- private onMessageValidators: {[message: string]: StandardSchemaV1} = {};
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; }
348
374
 
349
- private onMessageFallbacks = {
350
- '__no_message_handler': (client: ExtractRoomClient<T>, messageType: string | number, _: unknown) => {
351
- const errorMessage = `room onMessage for "${messageType}" not registered.`;
352
- debugMessage(`${errorMessage} (roomId: ${this.roomId})`);
353
-
354
- if (isDevMode) {
355
- // send error code to client in development mode
356
- client.error(ErrorCode.INVALID_PAYLOAD, errorMessage);
375
+ private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
357
376
 
358
- } else {
359
- // immediately close the connection in production
360
- client.leave(CloseCode.WITH_ERROR, errorMessage);
361
- }
362
- }
363
- };
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> = [];
364
381
 
365
- private _serializer: Serializer<ExtractRoomState<T>> = noneSerializer;
366
- private _afterNextPatchQueue: Array<[string | number | ExtractRoomClient<T>, ArrayLike<any>]> = [];
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 }> = [];
367
388
 
368
389
  private _simulationInterval: NodeJS.Timeout;
369
390
 
@@ -589,83 +610,83 @@ export class Room<T extends RoomOptions = RoomOptions> {
589
610
  public onLeave?(client: ExtractRoomClient<T>, code?: number): void | Promise<any>;
590
611
 
591
612
  /**
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.
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.)
595
619
  *
596
620
  * @example
597
621
  * ```typescript
598
622
  * class FpsRoom extends Room<{ input: MoveInput }> {
599
- * input = this.defineInput(MoveInput);
623
+ * inputs = this.defineInput(MoveInput);
600
624
  *
601
625
  * 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);
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);
611
631
  * }
612
632
  * }
613
633
  * ```
614
634
  */
615
- public input?: InputAPI<ExtractRoomInput<T>>;
635
+ public inputs?: InputAPI<ExtractRoomInput<T>>;
616
636
 
617
637
  /**
618
- * Input configuration. Set via {@link defineInput} only.
619
- * @internal
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}.
620
642
  */
621
- private inputOptions?: InputOptions;
643
+ private _inputController?: RoomInput;
622
644
 
623
645
  /**
624
646
  * 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.
647
+ * input API that gets assigned to `this.inputs` — call
648
+ * `this.inputs.get(sessionId)` per tick to consume.
627
649
  *
628
650
  * ```typescript
629
651
  * class FpsRoom extends Room<{ input: MoveInput }> {
630
- * input = this.defineInput(MoveInput, {
652
+ * inputs = this.defineInput(MoveInput, {
631
653
  * seqField: "tick", // typed: only numeric fields of MoveInput
632
654
  * bufferMaxSize: 64,
633
655
  * });
634
656
  *
635
- * // …or without options — defaults to seqField: "seq", bufferMaxSize: 32:
636
- * // input = this.defineInput(MoveInput);
657
+ * // …or without options — no seq dedupe, bufferMaxSize: 32:
658
+ * // inputs = this.defineInput(MoveInput);
637
659
  * }
638
660
  * ```
639
661
  *
640
662
  * **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.
663
+ * - `seqField`: unsetdedupe 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.
643
667
  * - `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).
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.
646
678
  */
647
- protected defineInput<C extends new () => any>(
679
+ protected defineInput<
680
+ C extends new () => any,
681
+ O extends DefineInputOptions<InstanceType<C>> = DefineInputOptions<InstanceType<C>>,
682
+ >(
648
683
  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
- });
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);
669
690
  }
670
691
 
671
692
  /**
@@ -681,7 +702,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
681
702
  * - `onMessage`
682
703
  * - `onAuth` / `onJoin` / `onLeave` / `onCreate` / `onDispose`
683
704
  * - `clock.setTimeout` / `clock.setInterval`
684
- * - `setSimulationInterval`
705
+ * - `setTimestep` / `setFixedTimestep`
685
706
  *
686
707
  * (Experimental: this feature is subject to change in the future - we're currently getting feedback to improve it)
687
708
  */
@@ -783,7 +804,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
783
804
 
784
805
  } else if (typeof(reconnectionToken) === "string") {
785
806
  // potentially a stale client reference, so a reconnection attempt is possible.
786
- return this.clients.getById(sessionId)?.reconnectionToken === reconnectionToken;
807
+ return this.clients.get(sessionId)?.reconnectionToken === reconnectionToken;
787
808
  }
788
809
 
789
810
  return false;
@@ -807,22 +828,29 @@ export class Room<T extends RoomOptions = RoomOptions> {
807
828
  }
808
829
 
809
830
  /**
810
- * (Optional) Set a simulation interval that can change the state of the game.
811
- * 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.
812
835
  *
813
836
  * @default 16.6ms (60fps)
814
837
  *
815
- * @param onTickCallback - You can implement your physics or world updates here!
816
- * This is a good place to update the room state.
817
- * @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.
818
841
  */
819
- public setSimulationInterval(onTickCallback?: SimulationCallback, delay: number = DEFAULT_SIMULATION_INTERVAL): void {
820
- // 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
821
844
  if (this._simulationInterval) { clearInterval(this._simulationInterval); }
822
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
+
823
851
  if (onTickCallback) {
824
852
  if (this.onUncaughtException !== undefined) {
825
- onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), SimulationIntervalException, 'setSimulationInterval');
853
+ onTickCallback = wrapTryCatch(onTickCallback, this.onUncaughtException.bind(this), TimestepException, 'setTimestep');
826
854
  }
827
855
 
828
856
  this._simulationInterval = setInterval(() => {
@@ -833,49 +861,179 @@ export class Room<T extends RoomOptions = RoomOptions> {
833
861
  }
834
862
 
835
863
  /**
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.
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.
840
880
  *
841
- * Replaces any previous {@link setSimulationInterval}. The current tick is
842
- * exposed via {@link tick}.
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}.
843
884
  *
844
- * @example
845
- * ```typescript
846
- * class LockstepRoom extends Room<{ input: MoveInput }> {
847
- * input = this.defineInput(MoveInput, { seqField: "tick", bufferMaxSize: 64 });
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).
848
896
  *
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
- * }
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 });
858
924
  * }
859
925
  * ```
860
926
  */
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);
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();
871
983
  }
872
984
 
873
985
  /**
874
- * Current server tick. Incremented by {@link setTickedSimulation} after each
875
- * tick callback returns. Returns 0 when no ticked simulation is running.
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).
876
1007
  */
877
- public get tick(): number { return this.#_tick; }
878
- #_tick: number = 0;
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
+ }
879
1037
 
880
1038
  /**
881
1039
  * @deprecated Use `.patchRate=` instead.
@@ -1106,12 +1264,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
1106
1264
  ...args: MessageArgs<ExtractRoomClient<T>['~messages'][K], IBroadcastOptions>
1107
1265
  ) {
1108
1266
  const [message, options] = args;
1109
- if (options && options.afterNextPatch) {
1110
- delete options.afterNextPatch;
1111
- this._afterNextPatchQueue.push(['broadcast', [type, ...args]]);
1112
- return;
1113
- }
1114
-
1115
1267
  this.broadcastMessageType(type, message, options);
1116
1268
  }
1117
1269
 
@@ -1119,12 +1271,6 @@ export class Room<T extends RoomOptions = RoomOptions> {
1119
1271
  * Broadcast bytes (UInt8Arrays) to a particular room
1120
1272
  */
1121
1273
  public broadcastBytes(type: string | number, message: Uint8Array, options: IBroadcastOptions) {
1122
- if (options && options.afterNextPatch) {
1123
- delete options.afterNextPatch;
1124
- this._afterNextPatchQueue.push(['broadcastBytes', arguments]);
1125
- return;
1126
- }
1127
-
1128
1274
  this.broadcastMessageType(type as string, message, options);
1129
1275
  }
1130
1276
 
@@ -1144,10 +1290,33 @@ export class Room<T extends RoomOptions = RoomOptions> {
1144
1290
  return false;
1145
1291
  }
1146
1292
 
1147
- 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();
1148
1307
 
1149
- // broadcast messages enqueued for "after patch"
1150
- 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
+ }
1151
1320
 
1152
1321
  return hasChanges;
1153
1322
  }
@@ -1182,43 +1351,19 @@ export class Room<T extends RoomOptions = RoomOptions> {
1182
1351
  );
1183
1352
  public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
1184
1353
  messageType: string | number,
1185
- callback: (client: C, message: T) => void,
1354
+ callback: (client: C, message: T, ctx: MessageContext) => unknown,
1186
1355
  );
1187
1356
  public onMessage<T = any, C extends Client = ExtractRoomClient<T>>(
1188
1357
  messageType: string | number,
1189
1358
  validationSchema: StandardSchemaV1<T>,
1190
- callback: (client: C, message: T) => void,
1359
+ callback: (client: C, message: T, ctx: MessageContext) => unknown,
1191
1360
  );
1192
1361
  public onMessage<T = any>(
1193
1362
  _messageType: '*' | string | number,
1194
1363
  _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
1195
1364
  _callback?: (...args: any[]) => void,
1196
1365
  ) {
1197
- const messageType = _messageType.toString();
1198
-
1199
- const validationSchema = (typeof _callback === 'function')
1200
- ? _validationSchema as StandardSchemaV1<T>
1201
- : undefined;
1202
-
1203
- const callback = (validationSchema === undefined)
1204
- ? _validationSchema as (...args: any[]) => void
1205
- : _callback;
1206
-
1207
- const removeListener = this.onMessageEvents.on(messageType, (this.onUncaughtException !== undefined)
1208
- ? wrapTryCatch(callback, this.onUncaughtException.bind(this), OnMessageException, 'onMessage', false, _messageType)
1209
- : callback);
1210
-
1211
- if (validationSchema !== undefined) {
1212
- this.onMessageValidators[messageType] = validationSchema;
1213
- }
1214
-
1215
- // returns a method to unbind the callback
1216
- return () => {
1217
- removeListener();
1218
- if (this.onMessageEvents.events[messageType].length === 0) {
1219
- delete this.onMessageValidators[messageType];
1220
- }
1221
- };
1366
+ return this.#_messages.on(_messageType, _validationSchema as any, _callback);
1222
1367
  }
1223
1368
 
1224
1369
  public onMessageBytes<T = any, C extends Client = ExtractRoomClient<T>>(
@@ -1237,21 +1382,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1237
1382
  _validationSchema: StandardSchemaV1<T> | ((...args: any[]) => void),
1238
1383
  _callback?: (...args: any[]) => void,
1239
1384
  ) {
1240
- const messageType = `_$b${_messageType}`;
1241
-
1242
- const validationSchema = (typeof _callback === 'function')
1243
- ? _validationSchema as StandardSchemaV1<T>
1244
- : undefined;
1245
-
1246
- const callback = (validationSchema === undefined)
1247
- ? _validationSchema as (...args: any[]) => void
1248
- : _callback;
1249
-
1250
- if (validationSchema !== undefined) {
1251
- return this.onMessage(messageType, validationSchema as any, callback as any);
1252
- } else {
1253
- return this.onMessage(messageType, callback as any);
1254
- }
1385
+ return this.#_messages.on(`_$b${_messageType}`, _validationSchema as any, _callback);
1255
1386
  }
1256
1387
 
1257
1388
  // ---------------------------------------------------------------------------
@@ -1426,18 +1557,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
1426
1557
  // (each new reconnection receives a new reconnection token)
1427
1558
  client.reconnectionToken = generateId();
1428
1559
 
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
- }
1560
+ // Allocate per-client input state early so onJoin can read inputs.get(sid).latest.
1561
+ this._inputController?.allocate(client);
1441
1562
 
1442
1563
  if (this._reservedSeatTimeouts[sessionId]) {
1443
1564
  clearTimeout(this._reservedSeatTimeouts[sessionId]);
@@ -1457,7 +1578,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1457
1578
  if (
1458
1579
  this._reservedSeats[sessionId] === undefined &&
1459
1580
  connectionOptions?.reconnectionToken &&
1460
- this.clients.getById(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken
1581
+ this.clients.get(sessionId)?.reconnectionToken === connectionOptions.reconnectionToken
1461
1582
  ) {
1462
1583
  debugMatchMaking('attempting to reconnect client with a stale previous connection - sessionId: \'%s\', roomId: \'%s\'', client.sessionId, this.roomId);
1463
1584
  this._reconnectionAttempts[connectionOptions.reconnectionToken] = new Deferred();
@@ -1496,8 +1617,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
1496
1617
  this._reservedSeats[sessionId][2] = true; // flag seat reservation as "consumed"
1497
1618
  debugMatchMaking('consuming seat reservation, sessionId: \'%s\' (roomId: %s)', client.sessionId, this.roomId);
1498
1619
 
1499
- // share "after next patch queue" reference with every client.
1500
- 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;
1501
1624
 
1502
1625
  // add temporary callback to keep track of disconnections during `onJoin`.
1503
1626
  client.ref['onleave'] = (_) => client.state = ClientState.LEAVING;
@@ -1507,6 +1630,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
1507
1630
  const reconnectionToken = connectionOptions?.reconnectionToken;
1508
1631
  if (reconnectionToken && this._reconnections[reconnectionToken]?.[0] === sessionId) {
1509
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);
1510
1636
 
1511
1637
  //
1512
1638
  // await for reconnection:
@@ -1572,6 +1698,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
1572
1698
  }
1573
1699
 
1574
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);
1575
1703
 
1576
1704
  //
1577
1705
  // Flag sessionId as non-enumarable so hasReachedMaxClients() doesn't count it
@@ -1625,16 +1753,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
1625
1753
  // allow client to send messages after onJoin has succeeded.
1626
1754
  client.ref.on('message', this._onMessage.bind(this, client));
1627
1755
 
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
- }
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();
1638
1760
 
1639
1761
  // confirm room id that matches the room name requested to join
1640
1762
  client.raw(getMessageBytes[Protocol.JOIN_ROOM](
@@ -1786,46 +1908,67 @@ export class Room<T extends RoomOptions = RoomOptions> {
1786
1908
  : [options.except]
1787
1909
  : undefined;
1788
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;
1789
1924
  let numClients = this.clients.length;
1790
1925
  while (numClients--) {
1791
1926
  const client = this.clients[numClients];
1792
-
1793
1927
  if (!except || !except.includes(client)) {
1794
- client.enqueueRaw(encodedMessage);
1928
+ client.enqueueRaw(encodedMessage, sendOpts);
1795
1929
  }
1796
1930
  }
1797
1931
  }
1798
1932
 
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));
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;
1804
1948
  }
1805
1949
 
1806
1950
  private sendFullState(client: Client): void {
1807
- client.raw(this._serializer.getFullState(client));
1951
+ client.raw(this._serializer.getFullState(
1952
+ client,
1953
+ this._inputController !== undefined ? { sNow: this.clock.elapsedTime } : undefined,
1954
+ ));
1808
1955
  }
1809
1956
 
1810
- private _dequeueAfterPatchMessages() {
1811
- const length = this._afterNextPatchQueue.length;
1812
-
1813
- if (length > 0) {
1814
- for (let i = 0; i < length; i++) {
1815
- const [target, args] = this._afterNextPatchQueue[i];
1816
-
1817
- if (target === "broadcast") {
1818
- this.broadcast.apply(this, args as any);
1819
-
1820
- } else {
1821
- (target as Client).raw.apply(target, args as any);
1822
- }
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;
1823
1969
  }
1824
-
1825
- // new messages may have been added in the meantime,
1826
- // let's splice the ones that have been processed
1827
- this._afterNextPatchQueue.splice(0, length);
1828
1970
  }
1971
+ dirty.length = 0;
1829
1972
  }
1830
1973
 
1831
1974
  private async _reserveSeat(
@@ -1872,6 +2015,8 @@ export class Room<T extends RoomOptions = RoomOptions> {
1872
2015
  delete this._reconnections[devModeReconnectionToken];
1873
2016
  delete this._reservedSeats[sessionId];
1874
2017
  delete this._reservedSeatTimeouts[sessionId];
2018
+ // devMode reconnection bypasses #_onAfterLeave — drop the input accessor here.
2019
+ this._inputController?.release(sessionId);
1875
2020
 
1876
2021
  if (!allowReconnection) {
1877
2022
  await this.#_decrementClientCount();
@@ -1939,7 +2084,7 @@ export class Room<T extends RoomOptions = RoomOptions> {
1939
2084
  }
1940
2085
 
1941
2086
  if (this._autoDisposeTimeout) {
1942
- clearInterval(this._autoDisposeTimeout);
2087
+ clearTimeout(this._autoDisposeTimeout);
1943
2088
  this._autoDisposeTimeout = undefined;
1944
2089
  }
1945
2090
 
@@ -1947,24 +2092,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
1947
2092
  this.clock.clear();
1948
2093
  this.clock.stop();
1949
2094
 
1950
- return await (userReturnData || Promise.resolve());
1951
- }
2095
+ // drop any input accessors still held for in-flight reconnections
2096
+ this._inputController?.dispose();
1952
2097
 
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);
2098
+ return await (userReturnData || Promise.resolve());
1968
2099
  }
1969
2100
 
1970
2101
  private _onMessage(client: ExtractRoomClient<T> & ClientPrivate, buffer: Buffer) {
@@ -1987,153 +2118,25 @@ export class Room<T extends RoomOptions = RoomOptions> {
1987
2118
  }
1988
2119
 
1989
2120
  const it: Iterator = { offset: 1 };
1990
- 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;
1991
2125
 
1992
2126
  if (code === Protocol.ROOM_DATA) {
1993
- const messageType = (decode.stringCheck(buffer, it))
1994
- ? decode.string(buffer, it)
1995
- : decode.number(buffer, it);
1996
-
1997
- let message;
1998
- try {
1999
- message = (buffer.byteLength > it.offset)
2000
- ? unpack(buffer.subarray(it.offset, buffer.byteLength))
2001
- : undefined;
2002
- debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
2003
-
2004
- // custom message validation
2005
- if (this.onMessageValidators[messageType] !== undefined) {
2006
- message = standardValidate(this.onMessageValidators[messageType], message);
2007
- }
2008
-
2009
- } catch (e: any) {
2010
- debugAndPrintError(e);
2011
- client.leave(CloseCode.WITH_ERROR);
2012
- return;
2013
- }
2014
-
2015
- if (this.onMessageEvents.events[messageType]) {
2016
- this.onMessageEvents.emit(messageType as string, client, message);
2017
-
2018
- } else if (this.onMessageEvents.events['*']) {
2019
- this.onMessageEvents.emit('*', client, messageType, message);
2020
-
2021
- } else {
2022
- this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
2023
- }
2127
+ this.#_messages.onData(client, buffer, it);
2024
2128
 
2025
2129
  } 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
- );
2130
+ this.#_messages.onRequest(client, buffer, it);
2085
2131
 
2086
2132
  } else if (code === Protocol.ROOM_DATA_BYTES) {
2087
- const messageType = (decode.stringCheck(buffer, it))
2088
- ? decode.string(buffer, it)
2089
- : decode.number(buffer, it);
2090
-
2091
- let message: any = buffer.subarray(it.offset, buffer.byteLength);
2092
- debugMessage("received: '%s' -> %j (roomId: %s)", messageType, message, this.roomId);
2093
-
2094
- const bytesMessageType = `_$b${messageType}`;
2095
-
2096
- // custom message validation
2097
- try {
2098
- if (this.onMessageValidators[bytesMessageType] !== undefined) {
2099
- message = standardValidate(this.onMessageValidators[bytesMessageType], message);
2100
- }
2101
- } catch (e: any) {
2102
- debugAndPrintError(e);
2103
- client.leave(CloseCode.WITH_ERROR);
2104
- return;
2105
- }
2106
-
2107
- if (this.onMessageEvents.events[bytesMessageType]) {
2108
- this.onMessageEvents.emit(bytesMessageType, client, message);
2109
-
2110
- } else if (this.onMessageEvents.events['*']) {
2111
- this.onMessageEvents.emit('*', client, messageType, message);
2112
-
2113
- } else {
2114
- this.onMessageFallbacks['__no_message_handler'](client, messageType, message);
2115
- }
2133
+ this.#_messages.onDataBytes(client, buffer, it);
2116
2134
 
2117
2135
  } 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
- }
2136
+ this._inputController?.decodeReliable(client, buffer, it, modifiers);
2127
2137
 
2128
2138
  } 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
- }
2139
+ this._inputController?.decodeUnreliable(client, buffer, modifiers);
2137
2140
 
2138
2141
  } else if (code === Protocol.JOIN_ROOM && client.state === ClientState.JOINING) {
2139
2142
  // join room has been acknowledged by the client
@@ -2183,6 +2186,10 @@ export class Room<T extends RoomOptions = RoomOptions> {
2183
2186
  return;
2184
2187
  }
2185
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
+
2186
2193
  if (method) {
2187
2194
  debugMatchMaking(`${method.name}, sessionId: \'%s\' (close code: %d, roomId: %s)`, client.sessionId, code, this.roomId);
2188
2195
 
@@ -2223,6 +2230,9 @@ export class Room<T extends RoomOptions = RoomOptions> {
2223
2230
 
2224
2231
  // trigger 'leave' only if seat reservation has been fully consumed
2225
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);
2226
2236
  this._events.emit('leave', client, willDispose);
2227
2237
  }
2228
2238
  }
@@ -2309,53 +2319,4 @@ export class Room<T extends RoomOptions = RoomOptions> {
2309
2319
  }
2310
2320
  }
2311
2321
 
2312
- }
2313
-
2314
- /**
2315
- * (WIP) Alternative, method-based room definition.
2316
- * We should be able to define
2317
- */
2318
-
2319
- type RoomLifecycleMethods =
2320
- | 'messages'
2321
- | 'onCreate'
2322
- | 'onJoin'
2323
- | 'onLeave'
2324
- | 'onDispose'
2325
- | 'onCacheRoom'
2326
- | 'onRestoreRoom'
2327
- | 'onDrop'
2328
- | 'onReconnect'
2329
- | 'onUncaughtException'
2330
- | 'onAuth'
2331
- | 'onBeforeShutdown'
2332
- | 'onBeforePatch';
2333
-
2334
- type DefineRoomOptions<T extends RoomOptions = RoomOptions> =
2335
- Partial<Pick<Room<T>, RoomLifecycleMethods>> &
2336
- { state?: ExtractRoomState<T> | (() => ExtractRoomState<T>); } &
2337
- ThisType<Exclude<Room<T>, RoomLifecycleMethods>> &
2338
- ThisType<Room<T>>
2339
- ;
2340
-
2341
- export function room<T>(options: DefineRoomOptions<T>) {
2342
- class _ extends Room<T> {
2343
- messages = options.messages;
2344
-
2345
- constructor() {
2346
- super();
2347
- if (options.state && typeof options.state === 'function') {
2348
- this.state = options.state();
2349
- }
2350
- }
2351
- }
2352
-
2353
- // Copy all methods to the prototype
2354
- for (const key in options) {
2355
- if (typeof options[key] === 'function') {
2356
- _.prototype[key] = options[key];
2357
- }
2358
- }
2359
-
2360
- return _ as typeof Room<T>;
2361
2322
  }