@series-inc/rundot-syncplay 5.25.0-beta.2 → 5.25.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -23,7 +23,8 @@ export { rewindDeterministicTargets, runDeterministicMovementCrowdSimulation, ru
23
23
  export { stepDeterministicCarry3D, stepDeterministicKcc3D } from './movement3d.js';
24
24
  export { applyDeterministicReciprocalAvoidance, cookDeterministicNavigationGraph, cookTilemapNavigationGraph, createDeterministicFlowField, findDeterministicPath, smoothDeterministicPath, stepDeterministicNavigationAgents } from './navigation.js';
25
25
  export { cookDeterministicNavmesh, cookImportedDeterministicNavmesh, findDeterministicNavmeshPath, findDeterministicNavmeshPointPath, locateDeterministicNavmeshPolygon, runDeterministicNavmeshStress, smoothDeterministicNavmeshPath, stepDeterministicNavmeshAgents, stepDeterministicNavmeshOffMeshTraversal } from './navmesh.js';
26
- export { createNetworkedSyncplayClient } from './networked-client.js';
26
+ export { createNetworkedSyncplayClient, prepareNetworkedSyncplayTransport } from './networked-client.js';
27
+ export { createSyncplayRunner } from './runner.js';
27
28
  export { fbm2D, hash2, hashUint32, valueNoise1D, valueNoise2D } from './noise.js';
28
29
  export { circleCastDeterministicPhysics2D, createDeterministicPhysicsWorld2D, nearestDeterministicPhysicsHit2D, queryDeterministicPhysicsAabb2D, queryDeterministicPhysicsShapeOverlap2D, raycastAllDeterministicPhysics2D, raycastDeterministicPhysics2D, stepDeterministicPhysicsWorld2D } from './physics2d.js';
29
30
  export { applyDeterministicImpulse3D, applyDeterministicImpulses3D, applyDeterministicWorldEdit3D, contactDetailsDeterministicPhysics3D, createDeterministicPhysicsWorld3D, createTrustedDeterministicPhysicsWorld3D, deriveInverseInertia, deterministicPhysicsBodyById3D, deterministicPhysicsCallback3D, overlapDeterministicPhysics3D, raycastDeterministicPhysics3D, raycastDeterministicPhysics3DDetailed, runDeterministicPhysics3DStress, setDeterministicBodyVelocity3D, shapeCastDeterministicPhysics3D, shapeCastDeterministicPhysics3DDetailed, stepDeterministicPhysicsWorld3D } from './physics3d.js';
@@ -1,6 +1,6 @@
1
1
  import type { EncodedInput } from './input-authority.js';
2
2
  import { type DeterministicSessionRole } from './session-wire.js';
3
- import { type KinetixRuntime, type KinetixRuntimeIdentity } from './runtime-session.js';
3
+ import { type KinetixRuntimeIdentity, type ProjectionCapableRuntime } from './runtime-session.js';
4
4
  import type { ReplayFile } from './types.js';
5
5
  import { type SyncplaySecretsClient } from './secret-client.js';
6
6
  import type { SyncplaySecretBrowserCrypto } from './secret-crypto.js';
@@ -21,11 +21,18 @@ export interface NetworkedClientTransport {
21
21
  onSecretMessage?(handler: (text: string) => void): void;
22
22
  readonly playerId?: string;
23
23
  }
24
- export type KinetixRuntimeFactory<State, Input, Checkpoint = unknown> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) => KinetixRuntime<State, Input, Checkpoint>;
25
- export interface NetworkedSyncplayClientOptions<State, Input, Checkpoint = unknown> {
24
+ export type KinetixRuntimeFactory<State, Input, Checkpoint = unknown, Projection = State> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) => ProjectionCapableRuntime<State, Input, Checkpoint, Projection>;
25
+ export interface NetworkedSyncplayClientOptions<State, Input, Checkpoint = unknown, Projection = State> {
26
26
  readonly transport: NetworkedClientTransport;
27
- readonly runtimeFactory: KinetixRuntimeFactory<State, Input, Checkpoint>;
27
+ readonly runtimeFactory: KinetixRuntimeFactory<State, Input, Checkpoint, Projection>;
28
28
  readonly defaultInput: Input;
29
+ /**
30
+ * Enables a bounded queue of authority-confirmed presentation frames drained
31
+ * via {@link NetworkedSyncplayClient.drainConfirmedPresentationFrames}. Absent
32
+ * means no queue and preserves existing client cost. Must be a positive safe
33
+ * integer.
34
+ */
35
+ readonly confirmedPresentationBufferSize?: number;
29
36
  readonly snapshotBufferSize?: number;
30
37
  readonly checksumIntervalFrames?: number;
31
38
  /** This client's own input for a tick (game-defined). */
@@ -107,7 +114,29 @@ export interface NetworkedSyncplayClientNetStats {
107
114
  readonly maxRollbackDepth: number;
108
115
  }
109
116
  export type NetworkedSyncplaySlotPresence = 'human' | 'substituted' | 'empty' | 'unknown';
110
- export interface NetworkedSyncplayClient {
117
+ export interface ConfirmedSyncplayPresentationFrame<Projection> {
118
+ readonly frame: number;
119
+ readonly projection: Readonly<Projection>;
120
+ }
121
+ export interface NetworkedSyncplaySessionDescriptor {
122
+ readonly slot: number;
123
+ readonly playerCount: number;
124
+ readonly seed: number;
125
+ readonly tickRateHz: number;
126
+ readonly hardToleranceTicks: number;
127
+ readonly role: DeterministicSessionRole;
128
+ readonly runtimeIdentity: KinetixRuntimeIdentity;
129
+ readonly sessionConfigBytes: Uint8Array;
130
+ readonly sessionConfigDigest: string;
131
+ }
132
+ export interface PreparedNetworkedSyncplayTransport {
133
+ readonly descriptor: NetworkedSyncplaySessionDescriptor;
134
+ readonly transport: NetworkedClientTransport;
135
+ }
136
+ export interface NetworkedSyncplayPreparationOptions {
137
+ readonly signal?: AbortSignal;
138
+ }
139
+ export interface NetworkedSyncplayClient<Projection = unknown> {
111
140
  readonly slot: number;
112
141
  /** 'player' (holds a slot) or 'spectator' (read-only follower, slot -1). From session-start. */
113
142
  readonly role: DeterministicSessionRole;
@@ -138,5 +167,21 @@ export interface NetworkedSyncplayClient {
138
167
  /** Confirmed-state checksum at `frame` (must be <= appliedThrough + 1). */
139
168
  confirmedStateChecksum(frame: number): string;
140
169
  exportReplay(): ReplayFile;
170
+ readonly currentFrame: number;
171
+ readonly renderAlpha: number;
172
+ readonly caughtUp: boolean;
173
+ getPresentationFrame(): Readonly<Projection>;
174
+ drainConfirmedPresentationFrames(): readonly ConfirmedSyncplayPresentationFrame<Projection>[];
175
+ beginPresentationCatchUp(): void;
176
+ dispose(): void;
141
177
  }
142
- export declare function createNetworkedSyncplayClient<State, Input, Checkpoint = unknown>(options: NetworkedSyncplayClientOptions<State, Input, Checkpoint>): NetworkedSyncplayClient;
178
+ /**
179
+ * Attaches to the ordered transport, awaits and validates the first
180
+ * `session-start`, exposes an immutable descriptor with detached session-config
181
+ * bytes, buffers subsequent public messages (same 8,192-message ceiling as the
182
+ * SDK transport), awaits `prepare`, then returns a replaying transport for
183
+ * {@link createNetworkedSyncplayClient}. Rejects on abort, terminal
184
+ * session-close/error, duplicate handler attachment, or overflow.
185
+ */
186
+ export declare function prepareNetworkedSyncplayTransport(transport: NetworkedClientTransport, prepare: (descriptor: NetworkedSyncplaySessionDescriptor, signal: AbortSignal) => Promise<void>, options?: NetworkedSyncplayPreparationOptions): Promise<PreparedNetworkedSyncplayTransport>;
187
+ export declare function createNetworkedSyncplayClient<State, Input, Checkpoint = unknown, Projection = State>(options: NetworkedSyncplayClientOptions<State, Input, Checkpoint, Projection>): NetworkedSyncplayClient<Projection>;
@@ -39,6 +39,141 @@ function equalBytes(left, right) {
39
39
  }
40
40
  return true;
41
41
  }
42
+ const MAX_RUNTIME_PREPARATION_MESSAGES = 8_192;
43
+ function immutableDescriptor(message) {
44
+ return Object.freeze({
45
+ slot: message.slot,
46
+ playerCount: message.playerCount,
47
+ seed: message.seed,
48
+ tickRateHz: message.tickRateHz,
49
+ hardToleranceTicks: message.hardToleranceTicks,
50
+ role: message.role,
51
+ runtimeIdentity: Object.freeze({ ...message.runtimeIdentity }),
52
+ sessionConfigBytes: new Uint8Array(message.sessionConfigBytes),
53
+ sessionConfigDigest: message.sessionConfigDigest,
54
+ });
55
+ }
56
+ /**
57
+ * Attaches to the ordered transport, awaits and validates the first
58
+ * `session-start`, exposes an immutable descriptor with detached session-config
59
+ * bytes, buffers subsequent public messages (same 8,192-message ceiling as the
60
+ * SDK transport), awaits `prepare`, then returns a replaying transport for
61
+ * {@link createNetworkedSyncplayClient}. Rejects on abort, terminal
62
+ * session-close/error, duplicate handler attachment, or overflow.
63
+ */
64
+ export async function prepareNetworkedSyncplayTransport(transport, prepare, options = {}) {
65
+ const localController = new AbortController();
66
+ const signal = options.signal ?? localController.signal;
67
+ const buffered = [];
68
+ let descriptorValue;
69
+ // A single gate for descriptor acquisition (resolve on session-start, reject on
70
+ // terminal/abort) keeps the descriptor→prepare handoff one microtask deep, so a
71
+ // caller that flushes a single microtask sees preparation already started.
72
+ let resolveDescriptor;
73
+ let rejectDescriptor;
74
+ const descriptorGate = new Promise((resolve, reject) => {
75
+ resolveDescriptor = resolve;
76
+ rejectDescriptor = reject;
77
+ });
78
+ void descriptorGate.catch(() => undefined);
79
+ let rejectTerminal;
80
+ const terminalPromise = new Promise((_resolve, reject) => {
81
+ rejectTerminal = reject;
82
+ });
83
+ void terminalPromise.catch(() => undefined);
84
+ let failure;
85
+ let preparationComplete = false;
86
+ let clientHandler;
87
+ // Tracks attachment independently of clientHandler: clientHandler is published
88
+ // only AFTER the replay drains (so re-entrant deliveries buffer in FIFO), but
89
+ // a second attach must still fail loud even if a replayed message threw
90
+ // mid-drain and left clientHandler unpublished.
91
+ let handlerAttached = false;
92
+ const fail = (error) => {
93
+ if (failure !== undefined || clientHandler !== undefined)
94
+ return;
95
+ failure = error;
96
+ if (!preparationComplete) {
97
+ rejectDescriptor(error);
98
+ rejectTerminal(error);
99
+ }
100
+ };
101
+ const abort = () => fail(new Error('SYNCPLAY_RUNTIME_PREPARATION_ABORTED'));
102
+ if (signal.aborted)
103
+ abort();
104
+ else
105
+ signal.addEventListener('abort', abort, { once: true });
106
+ transport.onMessage((text) => {
107
+ if (clientHandler !== undefined) {
108
+ clientHandler(text);
109
+ return;
110
+ }
111
+ if (failure !== undefined)
112
+ return;
113
+ if (buffered.length >= MAX_RUNTIME_PREPARATION_MESSAGES) {
114
+ fail(new Error('SYNCPLAY_RUNTIME_PREPARATION_BUFFER_OVERFLOW'));
115
+ return;
116
+ }
117
+ buffered.push(text);
118
+ const decoded = decodeDeterministicSessionMessage(text);
119
+ if (!decoded.ok)
120
+ return;
121
+ if (decoded.message.kind === 'error') {
122
+ fail(new Error(decoded.message.message));
123
+ return;
124
+ }
125
+ if (decoded.message.kind === 'close') {
126
+ fail(new Error(decoded.message.reason));
127
+ return;
128
+ }
129
+ if (decoded.message.kind !== 'session-start')
130
+ return;
131
+ if (descriptorValue !== undefined) {
132
+ fail(new Error('SYNCPLAY_RUNTIME_PREPARATION_SESSION_RESTARTED'));
133
+ return;
134
+ }
135
+ descriptorValue = immutableDescriptor(decoded.message);
136
+ resolveDescriptor(descriptorValue);
137
+ });
138
+ try {
139
+ const descriptor = await descriptorGate;
140
+ await Promise.race([prepare(descriptor, signal), terminalPromise]);
141
+ if (failure !== undefined)
142
+ throw failure;
143
+ preparationComplete = true;
144
+ const replayingTransport = {
145
+ send: (text) => transport.send(text),
146
+ onMessage: (handler) => {
147
+ if (handlerAttached) {
148
+ throw new Error('SYNCPLAY_RUNTIME_PREPARATION_HANDLER_ALREADY_ATTACHED');
149
+ }
150
+ if (failure !== undefined)
151
+ throw failure;
152
+ handlerAttached = true;
153
+ // Drain the buffer BEFORE publishing clientHandler: while it is still
154
+ // undefined, a message the client emits synchronously during replay
155
+ // loops back through the source onMessage into `buffered` (FIFO) instead
156
+ // of jumping ahead of the not-yet-replayed tail.
157
+ while (buffered.length > 0) {
158
+ const text = buffered.shift();
159
+ handler(text);
160
+ }
161
+ clientHandler = handler;
162
+ },
163
+ ...(transport.sendSecret === undefined
164
+ ? {}
165
+ : { sendSecret: (text) => transport.sendSecret(text) }),
166
+ ...(transport.onSecretMessage === undefined
167
+ ? {}
168
+ : { onSecretMessage: (handler) => transport.onSecretMessage(handler) }),
169
+ ...(transport.playerId === undefined ? {} : { playerId: transport.playerId }),
170
+ };
171
+ return Object.freeze({ descriptor, transport: replayingTransport });
172
+ }
173
+ finally {
174
+ signal.removeEventListener('abort', abort);
175
+ }
176
+ }
42
177
  export function createNetworkedSyncplayClient(options) {
43
178
  const sessionMsgType = options.sessionMsgType ?? DEFAULT_MSG_TYPE;
44
179
  const maxPredictionTicks = options.maxPredictionTicks ?? DEFAULT_MAX_PREDICTION;
@@ -59,6 +194,7 @@ export function createNetworkedSyncplayClient(options) {
59
194
  const inputDelayMaxTicks = Math.max(options.inputDelay?.maxTicks ?? inputDelayMinTicks, inputDelayMinTicks);
60
195
  const wallClock = options.now ?? (() => Date.now());
61
196
  let session;
197
+ let activeRuntime;
62
198
  let slot = -1;
63
199
  let role = 'player';
64
200
  let seed = 0;
@@ -67,6 +203,17 @@ export function createNetworkedSyncplayClient(options) {
67
203
  let appliedThrough = -1;
68
204
  let rollbacks = 0;
69
205
  let maxRollbackDepth = 0;
206
+ let hardToleranceTicks = 0;
207
+ let latestAuthorityTick = -1;
208
+ let caughtUp = false;
209
+ let presentationCatchUpFloor;
210
+ let disposed = false;
211
+ const confirmedPresentationFrames = [];
212
+ const confirmedPresentationBufferSize = options.confirmedPresentationBufferSize;
213
+ if (confirmedPresentationBufferSize !== undefined
214
+ && (!Number.isSafeInteger(confirmedPresentationBufferSize) || confirmedPresentationBufferSize < 1)) {
215
+ throw new Error('SYNCPLAY_CONFIRMED_PROJECTION_BUFFER_INVALID');
216
+ }
70
217
  // ── G1/G2 pacing state (only advanced by pumpPaced; inert under raw pump) ──
71
218
  let tickRateHz = 0;
72
219
  let frameMs = 0;
@@ -111,10 +258,25 @@ export function createNetworkedSyncplayClient(options) {
111
258
  const outgoingTransferQueue = [];
112
259
  let donorTransferCounter = 0;
113
260
  let readyResolve;
114
- const readyPromise = new Promise((resolve) => {
261
+ let readyReject;
262
+ const readyPromise = new Promise((resolve, reject) => {
115
263
  readyResolve = resolve;
264
+ readyReject = reject;
116
265
  });
266
+ void readyPromise.catch(() => undefined);
267
+ function assertActiveClient() {
268
+ if (disposed)
269
+ throw new Error('SYNCPLAY_CLIENT_DISPOSED');
270
+ }
271
+ function requireSession() {
272
+ assertActiveClient();
273
+ if (session === undefined)
274
+ throw new Error('SYNCPLAY_CLIENT_NOT_READY');
275
+ return session;
276
+ }
117
277
  options.transport.onMessage((text) => {
278
+ if (disposed)
279
+ return;
118
280
  const decoded = decodeDeterministicSessionMessage(text);
119
281
  if (!decoded.ok) {
120
282
  // The other frozen wire family (snapshot negotiation) rides the same
@@ -134,6 +296,15 @@ export function createNetworkedSyncplayClient(options) {
134
296
  role = message.role;
135
297
  seed = message.seed;
136
298
  playerCount = message.playerCount;
299
+ hardToleranceTicks = message.hardToleranceTicks;
300
+ latestAuthorityTick = -1;
301
+ caughtUp = false;
302
+ // A re-greet resets the tick space; a catch-up floor from the prior session
303
+ // is meaningless here (it would suppress `caughtUp` until the new session
304
+ // re-reached an old tick number). The runner's own recovery flag bridges
305
+ // event-silence across the re-hydrate.
306
+ presentationCatchUpFloor = undefined;
307
+ confirmedPresentationFrames.length = 0;
137
308
  lastConfirmedBySlot = Array.from({ length: playerCount }, () => null);
138
309
  confirmedPlayerPresence = unknownPresence();
139
310
  appliedThrough = -1;
@@ -168,6 +339,7 @@ export function createNetworkedSyncplayClient(options) {
168
339
  });
169
340
  }
170
341
  session?.dispose();
342
+ activeRuntime = runtime;
171
343
  session = createSyncplaySession(runtime, {
172
344
  playerCount,
173
345
  defaultInput: options.defaultInput,
@@ -185,7 +357,7 @@ export function createNetworkedSyncplayClient(options) {
185
357
  }
186
358
  else if (message.kind === 'confirmed-input') {
187
359
  observeAuthorityTick(message.authorityTick, wallClock());
188
- receiveConfirmedFrames(message.frames);
360
+ receiveConfirmedFrames(message.frames, message.authorityTick);
189
361
  }
190
362
  else if (message.kind === 'time-sync-pong') {
191
363
  handleTimeSyncPong(message.nonce, message.authorityTick);
@@ -399,6 +571,7 @@ export function createNetworkedSyncplayClient(options) {
399
571
  }
400
572
  }
401
573
  function pump() {
574
+ assertActiveClient();
402
575
  if (secretFailure)
403
576
  throw secretFailure;
404
577
  if (session === undefined) {
@@ -410,6 +583,7 @@ export function createNetworkedSyncplayClient(options) {
410
583
  }
411
584
  }
412
585
  function pumpPaced(nowMs) {
586
+ assertActiveClient();
413
587
  if (secretFailure)
414
588
  throw secretFailure;
415
589
  flushOutgoingTransfer();
@@ -447,36 +621,37 @@ export function createNetworkedSyncplayClient(options) {
447
621
  options.transport.send(encodeDeterministicSessionMessage({ kind: 'input', slot, tick, input, attempt: 0 }));
448
622
  }
449
623
  function confirmedStateChecksum(frame) {
450
- const active = session;
451
- if (active === undefined) {
452
- return '';
453
- }
624
+ const active = requireSession();
454
625
  active.restoreToFrame(frame);
455
626
  return active.getWireStateChecksum(frame);
456
627
  }
457
- function receiveConfirmedFrames(frames) {
628
+ function receiveConfirmedFrames(frames, authorityTick) {
458
629
  const containsSecretCommands = frames.some((frame) => (frame.commands ?? []).some((command) => (command !== null && typeof command === 'object' && !Array.isArray(command)
459
630
  && typeof command.kind === 'string'
460
631
  && command.kind.startsWith('syncplay.'))));
461
632
  if (!containsSecretCommands && confirmationQueue === undefined) {
462
- applyConfirmedFrames(frames);
633
+ applyConfirmedFrames(frames, authorityTick);
463
634
  return;
464
635
  }
465
636
  const prior = confirmationQueue ?? Promise.resolve();
466
637
  const next = prior.then(async () => {
467
638
  for (const frame of frames)
468
639
  await secrets.acceptConfirmedCommands(frame.commands ?? []);
469
- applyConfirmedFrames(frames);
640
+ applyConfirmedFrames(frames, authorityTick);
470
641
  });
471
642
  confirmationQueue = next;
472
- void next.catch((error) => {
473
- secretFailure = error instanceof Error ? error : new Error('signature-invalid');
643
+ void next.catch((caught) => {
644
+ secretFailure = caught instanceof Error ? caught : new Error('signature-invalid');
474
645
  }).finally(() => {
475
646
  if (confirmationQueue === next)
476
647
  confirmationQueue = undefined;
477
648
  });
478
649
  }
479
- function applyConfirmedFrames(frames) {
650
+ function applyConfirmedFrames(frames, authorityTick) {
651
+ const firstNewTick = frames.find((frame) => frame.tick > appliedThrough)?.tick;
652
+ const presentationWasLive = caughtUp;
653
+ const freshRoomFirstFrame = appliedThrough === -1 && firstNewTick === 0 && authorityTick === 0;
654
+ latestAuthorityTick = Math.max(latestAuthorityTick, authorityTick);
480
655
  for (const frame of frames) {
481
656
  if (frame.tick > appliedThrough) {
482
657
  confirmed.set(frame.tick, {
@@ -496,25 +671,52 @@ export function createNetworkedSyncplayClient(options) {
496
671
  if (predFrame === undefined || canonicalStringify(simulationFrame) !== canonicalStringify(predFrame)) {
497
672
  timeline[tick] = { inputs: confInputs.slice(), commands: confFrame.commands };
498
673
  if (predictedThrough > tick) {
499
- // Rollback: we had already predicted this tick and the confirmed inputs differ.
500
674
  rollbacks += 1;
501
675
  maxRollbackDepth = Math.max(maxRollbackDepth, predictedThrough - tick);
502
676
  simulate(tick);
503
677
  }
504
678
  else {
505
- // Confirm leads prediction (a lagging client): the authority confirmed a tick we had not
506
- // predicted yet. Extend the predicted head and apply the confirmed inputs so the session
507
- // actually advances through it — otherwise appliedThrough would outrun the simulated state.
508
679
  predictedThrough = tick + 1;
509
680
  simulate(tick);
510
681
  }
511
682
  }
512
- for (let s = 0; s < playerCount; s += 1) {
513
- lastConfirmedBySlot[s] = confInputs[s];
683
+ for (let playerSlot = 0; playerSlot < playerCount; playerSlot += 1) {
684
+ lastConfirmedBySlot[playerSlot] = confInputs[playerSlot];
514
685
  }
515
686
  appliedThrough = tick;
516
687
  confirmedPlayerPresence = classifyPresence(confFrame);
688
+ const presentationEligible = presentationWasLive || (freshRoomFirstFrame && tick === 0);
689
+ if (presentationEligible
690
+ && confirmedPresentationBufferSize !== undefined && activeRuntime?.project !== undefined) {
691
+ // Bounded ring: drop the oldest when full rather than throw. A consumer
692
+ // that stalls (e.g. a backgrounded tab whose rAF-driven drain pauses
693
+ // while the socket keeps delivering) must shed the debt and stay at the
694
+ // live tail, not throw uncaught out of the transport dispatch and leave a
695
+ // permanent gap. A single post-live batch fits well under the bound.
696
+ while (confirmedPresentationFrames.length >= confirmedPresentationBufferSize) {
697
+ confirmedPresentationFrames.shift();
698
+ }
699
+ const frame = tick + 1;
700
+ const selected = requireSession().peekSnapshot(frame);
701
+ const previous = requireSession().peekSnapshot(frame - 1);
702
+ if (selected === undefined)
703
+ throw new Error('SYNCPLAY_CONFIRMED_PROJECTION_MISSING');
704
+ confirmedPresentationFrames.push(Object.freeze({
705
+ frame,
706
+ projection: activeRuntime.project(selected.checkpoint, previous?.checkpoint),
707
+ }));
708
+ }
517
709
  }
710
+ // `caughtUp` requires having actually observed an authority tick — a joiner
711
+ // that hydrates from a snapshot before any confirmed-input broadcast has
712
+ // latestAuthorityTick === -1 and is not yet at any real boundary.
713
+ const reachedAuthorityBoundary = appliedThrough >= 0 && latestAuthorityTick >= 0
714
+ && appliedThrough >= latestAuthorityTick - hardToleranceTicks;
715
+ if (presentationCatchUpFloor !== undefined
716
+ && reachedAuthorityBoundary && appliedThrough > presentationCatchUpFloor) {
717
+ presentationCatchUpFloor = undefined;
718
+ }
719
+ caughtUp = reachedAuthorityBoundary && presentationCatchUpFloor === undefined;
518
720
  autoReportCrossedCadences();
519
721
  }
520
722
  function unknownPresence() {
@@ -754,7 +956,7 @@ export function createNetworkedSyncplayClient(options) {
754
956
  lastAutoReportedTick = Math.max(lastAutoReportedTick, snapshot.tick);
755
957
  // Frames that arrived before hydration are buffered in `confirmed` — the
756
958
  // normal contiguous-apply drains the tail from here.
757
- applyConfirmedFrames([]);
959
+ applyConfirmedFrames([], latestAuthorityTick);
758
960
  }
759
961
  return {
760
962
  get slot() {
@@ -788,12 +990,56 @@ export function createNetworkedSyncplayClient(options) {
788
990
  };
789
991
  },
790
992
  secrets,
993
+ get currentFrame() {
994
+ return requireSession().currentFrame;
995
+ },
996
+ get caughtUp() {
997
+ return session !== undefined && caughtUp;
998
+ },
999
+ get renderAlpha() {
1000
+ requireSession();
1001
+ return frameMs > 0 ? pacingAccumulatorMs / frameMs : 0;
1002
+ },
1003
+ getPresentationFrame() {
1004
+ return requireSession().getPresentationFrame();
1005
+ },
1006
+ drainConfirmedPresentationFrames() {
1007
+ requireSession();
1008
+ const drained = confirmedPresentationFrames.splice(0, confirmedPresentationFrames.length);
1009
+ return Object.freeze(drained);
1010
+ },
1011
+ beginPresentationCatchUp() {
1012
+ assertActiveClient();
1013
+ presentationCatchUpFloor = appliedThrough;
1014
+ caughtUp = false;
1015
+ confirmedPresentationFrames.length = 0;
1016
+ },
1017
+ dispose() {
1018
+ if (disposed)
1019
+ return;
1020
+ disposed = true;
1021
+ session?.dispose();
1022
+ session = undefined;
1023
+ activeRuntime = undefined;
1024
+ caughtUp = false;
1025
+ presentationCatchUpFloor = undefined;
1026
+ latestAuthorityTick = -1;
1027
+ confirmedPresentationFrames.length = 0;
1028
+ outgoingTransferQueue.length = 0;
1029
+ pendingPings.clear();
1030
+ const error = new Error('SYNCPLAY_CLIENT_DISPOSED');
1031
+ readyReject?.(error);
1032
+ readyResolve = undefined;
1033
+ readyReject = undefined;
1034
+ },
791
1035
  whenReady() {
1036
+ assertActiveClient();
792
1037
  return readyPromise;
793
1038
  },
794
1039
  pump,
795
1040
  pumpPaced,
796
1041
  reportChecksum() {
1042
+ assertActiveClient();
797
1043
  if (session === undefined || appliedThrough < 0) {
798
1044
  return;
799
1045
  }
@@ -801,10 +1047,7 @@ export function createNetworkedSyncplayClient(options) {
801
1047
  },
802
1048
  confirmedStateChecksum,
803
1049
  exportReplay() {
804
- if (session === undefined) {
805
- throw new Error('networked client has not started');
806
- }
807
- return session.exportReplay();
1050
+ return requireSession().exportReplay();
808
1051
  },
809
1052
  };
810
1053
  }
package/dist/node.d.ts CHANGED
@@ -20,3 +20,4 @@ export type { SyncplaySecretAuthorityCrypto, SyncplaySecretBrowserCrypto } from
20
20
  export * from './ws-frame.js';
21
21
  export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
22
22
  export type { ColliderCookOptions, ColliderSourceMesh, CookedCollider, CookedColliderHull, } from './collider-cooking.js';
23
+ export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
package/dist/node.js CHANGED
@@ -16,3 +16,6 @@ export * from './secret-authority.js';
16
16
  export { buildMerkleTree, bytesToHex, concatBytes, constantTimeEqual, createBrowserSyncplaySecretCrypto, hexToBytes, merkleLeafHash, merkleNodeHash, merkleProof, sha256Bytes, signedReceiptBytes, verifyHashChainReveal, verifyMerkleProof, verifySyncplaySignedReceipt, } from './secret-crypto.js';
17
17
  export * from './ws-frame.js';
18
18
  export { assertCookedColliderIsRuntimeValid, cookConvexDecomposition, } from './collider-cooking.js';
19
+ // Dev-server deterministic room class (relocated from the game SDK). A game's
20
+ // vite.config passes this to rundotMultiplayerPlugin({ deterministicRoomClass }).
21
+ export { DevSyncplayAuthorityRoom } from './dev-authority-room.js';
package/dist/replay.d.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { type KinetixRuntime, type KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
1
+ import { type KinetixRuntimeIdentity } from '@series-inc/rundot-kinetix/runtime';
2
2
  import { type SyncplaySessionPolicy } from './sdk-session.js';
3
+ import type { ProjectionCapableRuntime } from './runtime-session.js';
3
4
  import { type SyncplaySecretCommand } from './secret-protocol.js';
4
5
  import type { ReplayFile, ReplayVerificationResult } from './types.js';
5
- export type ReplayRuntimeFactory<State, Input, Checkpoint = unknown> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) => KinetixRuntime<State, Input, Checkpoint>;
6
- export interface ReplayVerificationConfig<State, Input, Checkpoint = unknown> {
6
+ export type ReplayRuntimeFactory<State, Input, Checkpoint = unknown, Projection = State> = (identity: KinetixRuntimeIdentity, sessionConfigBytes: Uint8Array) => ProjectionCapableRuntime<State, Input, Checkpoint, Projection>;
7
+ export interface ReplayVerificationConfig<State, Input, Checkpoint = unknown, Projection = State> {
7
8
  readonly replay: ReplayFile;
8
- readonly runtimeFactory: ReplayRuntimeFactory<State, Input, Checkpoint>;
9
- readonly policy: SyncplaySessionPolicy<State, Input, Checkpoint>;
9
+ readonly runtimeFactory: ReplayRuntimeFactory<State, Input, Checkpoint, Projection>;
10
+ readonly policy: SyncplaySessionPolicy<State, Input, Checkpoint, Projection>;
10
11
  readonly decodeInput?: (recordedInput: unknown, slot: number) => Input;
11
12
  readonly throwOnMismatch?: boolean;
12
13
  readonly expectedAuthorityId?: string;
@@ -15,6 +16,6 @@ export interface ReplayVerificationConfig<State, Input, Checkpoint = unknown> {
15
16
  export interface ReplayVerificationExecution<State> extends ReplayVerificationResult {
16
17
  readonly finalState: State;
17
18
  }
18
- export declare function verifyReplay<State, Input, Checkpoint = unknown>(config: ReplayVerificationConfig<State, Input, Checkpoint>): ReplayVerificationResult;
19
- export declare function executeReplay<State, Input, Checkpoint = unknown>(config: ReplayVerificationConfig<State, Input, Checkpoint>): ReplayVerificationExecution<State>;
19
+ export declare function verifyReplay<State, Input, Checkpoint = unknown, Projection = State>(config: ReplayVerificationConfig<State, Input, Checkpoint, Projection>): ReplayVerificationResult;
20
+ export declare function executeReplay<State, Input, Checkpoint = unknown, Projection = State>(config: ReplayVerificationConfig<State, Input, Checkpoint, Projection>): ReplayVerificationExecution<State>;
20
21
  export declare function assertReplayFileStructure(replay: ReplayFile): void;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Reusable, production-shaped client surface for deterministic multiplayer.
3
+ *
4
+ * A game joins a deterministic room by code (or creates one) and gets back a
5
+ * {@link NetworkedClientTransport} — the exact socket-like surface the
6
+ * deterministic engine's `createNetworkedSyncplayClient` consumes. There is
7
+ * NO per-game server logic: the server-side deterministic room (built-in input
8
+ * authority) is generic, and this transport is a thin, opaque pass-through of the
9
+ * frozen `rdm-local-authority/v2` session envelope over the `rdm` room channel.
10
+ *
11
+ * The room's raw channel ({@link ServerRoom.sendRaw}/{@link ServerRoom.onRaw})
12
+ * carries the session envelope verbatim; the typed-message path would spread a
13
+ * raw string and corrupt it, which is why the raw channel exists.
14
+ */
15
+ import type { NetworkedClientTransport } from './networked-client.js';
16
+ import type { KinetixRuntimeIdentity } from './runtime-session.js';
17
+ import type { MultiplayerApi, ServerPlayer } from '@series-inc/rundot-game-sdk/mp-client';
18
+ /** Connection-health signal surfaced by {@link SyncplayRoomTransport.onConnectionEvent}. */
19
+ export type SyncplayConnectionEvent = {
20
+ readonly kind: 'error';
21
+ readonly message: string;
22
+ } | {
23
+ readonly kind: 'disconnected';
24
+ } | {
25
+ readonly kind: 'reconnecting';
26
+ };
27
+ export interface SyncplayRoomOptions {
28
+ readonly maxPlayers?: number;
29
+ readonly runtimeIdentity: KinetixRuntimeIdentity;
30
+ readonly sessionConfigBytes: Uint8Array;
31
+ readonly sessionConfigDigest: string;
32
+ }
33
+ export interface SyncplayOccupancySnapshot {
34
+ readonly players: readonly ServerPlayer[];
35
+ readonly occupied: number;
36
+ readonly maxPlayers: number;
37
+ readonly full: boolean;
38
+ readonly locked: boolean;
39
+ }
40
+ export interface SyncplayRoomTransport extends NetworkedClientTransport {
41
+ /** The shareable room code (e.g. "HX9KWR"). */
42
+ readonly roomCode: string;
43
+ /** This client's player id, as assigned by the server on join. */
44
+ readonly playerId: string;
45
+ readonly occupancy: SyncplayOccupancySnapshot;
46
+ readonly isCreator: boolean;
47
+ /** Leave the deterministic room and close the underlying connection. */
48
+ close(): void;
49
+ /**
50
+ * Connection-health signal: socket errors, disconnects, and reconnect
51
+ * attempts. Without observing this, a mid-match socket drop is silent — the
52
+ * room layer no-ops sends while dead, so the engine would predict into a
53
+ * dead socket forever. Games should surface these to the player and/or tear
54
+ * the match down.
55
+ */
56
+ onConnectionEvent(handler: (event: SyncplayConnectionEvent) => void): void;
57
+ onOccupancyChange(handler: (snapshot: SyncplayOccupancySnapshot) => void): () => void;
58
+ setSeatingOpen(open: boolean): void;
59
+ }
60
+ /**
61
+ * Join an existing deterministic room by its 6-char code and return a transport
62
+ * ready to hand to `createNetworkedSyncplayClient`.
63
+ */
64
+ export declare function joinSyncplayRoomByCode(api: MultiplayerApi, code: string): Promise<SyncplayRoomTransport>;
65
+ /** Options for {@link quickMatchSyncplayRoom}. */
66
+ export interface QuickMatchSyncplayOptions extends SyncplayRoomOptions {
67
+ /**
68
+ * Auto-inject a coarse `region` criterion (default true). See
69
+ * {@link quickMatchSyncplayRoom} for the semantics and the tradeoff.
70
+ */
71
+ readonly autoRegion?: boolean;
72
+ }
73
+ /**
74
+ * Quick-match into a deterministic room (M9): the platform's joinOrCreate
75
+ * matchmaking pools compatible players into an open room or creates a fresh
76
+ * one. Optional `criteria` narrow the pool (matched exactly by the platform).
77
+ * With drop-in defaults the match starts immediately; unfilled slots
78
+ * substitute the canonical null input, which a game can treat as
79
+ * "bot-controlled" deterministically (see SYNCPLAY.md — Bots & quick match).
80
+ *
81
+ * Region-aware by default (G6): a coarse `region` criterion ('na' | 'sa' |
82
+ * 'eu' | 'africa' | 'asia' | 'oceania', derived from the device timezone) is
83
+ * auto-injected so cross-ocean pairings — whose RTT forces constant deep
84
+ * rollback — don't happen by accident. An explicit `criteria.region` wins
85
+ * over the derived value, and an underivable region ('unknown') is never
86
+ * injected: pooling globally beats fragmenting into an 'unknown' bucket.
87
+ *
88
+ * Tradeoff: strict region pools can go thin for low-traffic games. Pass
89
+ * `{ autoRegion: false }` (or your own `region` value) to pool globally;
90
+ * cross-region fallback-after-timeout is future work.
91
+ */
92
+ export declare function quickMatchSyncplayRoom(api: MultiplayerApi, options: QuickMatchSyncplayOptions, criteria?: Record<string, string | number>): Promise<SyncplayRoomTransport>;
93
+ /**
94
+ * Create a new deterministic room and return a transport ready to hand to
95
+ * `createNetworkedSyncplayClient`. The server mints the room code; read it
96
+ * back from {@link SyncplayRoomTransport.roomCode} to share with peers.
97
+ * (Caller-chosen codes are not supported on create — when the platform grows
98
+ * that path, this signature gains a typed parameter rather than a dead one.)
99
+ */
100
+ export declare function createSyncplayRoom(api: MultiplayerApi, options: SyncplayRoomOptions): Promise<SyncplayRoomTransport>;