@pylonsync/realtime 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ /**
2
+ * A shard connection with no framework: one WebSocket, reconnected with
3
+ * backoff, that decodes frames, applies replication frames to an
4
+ * `EntityTable`, and sends inputs. `useShard` in `@pylonsync/react` and
5
+ * `connectShardGame` build on it.
6
+ */
7
+
8
+ import { ShardClock } from "./clock";
9
+ import { EntityTable, type ReplicationSummary } from "./replication";
10
+ import {
11
+ SHARD_PROTOCOL_VERSION,
12
+ ShardFrameKind,
13
+ decodeShardPayload,
14
+ decodeShardRejection,
15
+ encodeShardInput,
16
+ parseShardFrame,
17
+ type ShardInputRejection,
18
+ type ShardPayloadDecoder,
19
+ } from "./wire";
20
+
21
+ export interface ShardConnectOptions {
22
+ /** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
23
+ subscriberId: string;
24
+ /**
25
+ * Auth token. Sent as a `bearer.<token>` WebSocket subprotocol, which
26
+ * keeps it out of URLs (proxy logs, devtools, and error telemetry record
27
+ * URLs, not subprotocols).
28
+ */
29
+ token?: string;
30
+ /**
31
+ * Shard ticket from a server function (`ctx.shards.ticket(...)`). Sent as
32
+ * a `ticket.<ticket>` WebSocket subprotocol. The shard checks it names
33
+ * this shard and `subscriberId`, and passes its claims to the game's
34
+ * authorization hooks.
35
+ *
36
+ * Tickets expire. Pass a function to get a new one for each connection
37
+ * attempt, so a reconnect after the expiry still gets in.
38
+ */
39
+ ticket?: string | (() => string | Promise<string>);
40
+ /** Host (and port) of the Pylon server. Defaults to `window.location.host`. */
41
+ baseUrl?: string;
42
+ /**
43
+ * Connect to the dedicated shard port instead of `/shard` on the main
44
+ * port (the dedicated port is the HTTP port + 3, e.g. 4324).
45
+ */
46
+ wsPort?: number;
47
+ /** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
48
+ wsUrl?: string;
49
+ /** Reconnect on unexpected close (default: true). */
50
+ autoReconnect?: boolean;
51
+ /** First reconnect delay in ms (default 500; doubles to at most 10 000). */
52
+ reconnectBackoffMs?: number;
53
+ /**
54
+ * Decoder for a payload codec the client does not know: bincode (`2`) or
55
+ * a game's own codec (`3`). JSON and MessagePack are built in.
56
+ */
57
+ decode?: ShardPayloadDecoder;
58
+ /** The shard's tick rate, when known; otherwise the clock measures it. */
59
+ tickRate?: number;
60
+ /** Monotonic time in ms. Default `performance.now()`. */
61
+ now?: () => number;
62
+ }
63
+
64
+ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
65
+ /** `ack` is the highest `send()` sequence number the shard has processed. */
66
+ onSnapshot: (fn: (snapshot: TSnapshot, tick: number, ack: number) => void) => void;
67
+ /** Called when the shard refuses an input (see `ShardInputRejection.code`). */
68
+ onInputRejected: (fn: (rejection: ShardInputRejection) => void) => void;
69
+ /**
70
+ * For a shard that replicates entities: called after each frame is
71
+ * applied to `entities`, with what it changed.
72
+ */
73
+ onReplication: (
74
+ fn: (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void,
75
+ ) => void;
76
+ /**
77
+ * The entities a replicating shard has sent this client. Read it each
78
+ * frame (a render loop); it changes in place as frames arrive.
79
+ */
80
+ readonly entities: EntityTable;
81
+ /** The shard's current tick, estimated from frame arrivals. */
82
+ readonly clock: ShardClock;
83
+ /** The tick of the last frame, or -1. */
84
+ readonly tick: number;
85
+ /** The ack of the last frame. */
86
+ readonly ack: number;
87
+ /**
88
+ * Smoothed time from sending an input to the first frame that
89
+ * acknowledges it (ms), or null before one has. It includes the wait for
90
+ * the shard's next tick.
91
+ */
92
+ readonly rttMs: number | null;
93
+ onError: (fn: (err: Error) => void) => void;
94
+ onOpen: (fn: () => void) => void;
95
+ onClose: (fn: () => void) => void;
96
+ /**
97
+ * Send an input. Returns its sequence number, which later frames
98
+ * acknowledge, or 0 when the connection is not open (the input is not
99
+ * sent, and `onError` hears why).
100
+ */
101
+ send: (input: TInput) => number;
102
+ close: () => void;
103
+ readonly connected: boolean;
104
+ }
105
+
106
+ /** Input send times kept for the round-trip estimate. */
107
+ const MAX_TIMED = 256;
108
+
109
+ /**
110
+ * Connect to a shard without React. Returns a client you can wire into any
111
+ * framework or render loop.
112
+ */
113
+ export function connectShard<TSnapshot = unknown, TInput = unknown>(
114
+ shardId: string,
115
+ options: ShardConnectOptions,
116
+ ): ShardClient<TSnapshot, TInput> {
117
+ const now = options.now ?? (() => performance.now());
118
+ let ws: WebSocket | null = null;
119
+ let clientSeq = 0;
120
+ let closed = false;
121
+ let connected = false;
122
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
123
+ let backoff = options.reconnectBackoffMs ?? 500;
124
+ // The shard's codec, learned from the first frame. Until then inputs go
125
+ // as JSON text, which every shard accepts.
126
+ let codec: number | null = null;
127
+ let lastTick = -1;
128
+ let lastAck = 0;
129
+ let rttMs: number | null = null;
130
+ // Send time per unacknowledged sequence number, oldest first.
131
+ const sentAt = new Map<number, number>();
132
+ const clock = new ShardClock({ tickRate: options.tickRate });
133
+
134
+ const snapshotHandlers: Array<(s: TSnapshot, t: number, ack: number) => void> = [];
135
+ const rejectionHandlers: Array<(r: ShardInputRejection) => void> = [];
136
+ const replicationHandlers: Array<
137
+ (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void
138
+ > = [];
139
+ const entities = new EntityTable();
140
+ const errorHandlers: Array<(e: Error) => void> = [];
141
+ const openHandlers: Array<() => void> = [];
142
+ const closeHandlers: Array<() => void> = [];
143
+
144
+ const dispatchError = (err: Error) => {
145
+ for (const h of errorHandlers) h(err);
146
+ };
147
+
148
+ const buildWsUrl = (): string => {
149
+ if (options.wsUrl) return options.wsUrl;
150
+ const proto =
151
+ typeof window !== "undefined" && window.location.protocol === "https:" ? "wss" : "ws";
152
+ // Only shard id + subscriber id land in the URL: routing metadata, not
153
+ // credentials.
154
+ const params = new URLSearchParams({
155
+ shard: shardId,
156
+ sid: options.subscriberId,
157
+ v: String(SHARD_PROTOCOL_VERSION),
158
+ });
159
+ if (options.wsPort !== undefined) {
160
+ const hostname = (
161
+ options.baseUrl ?? (typeof window !== "undefined" ? window.location.hostname : "localhost")
162
+ ).replace(/:\d+$/, "");
163
+ return `${proto}://${hostname}:${options.wsPort}/?${params.toString()}`;
164
+ }
165
+ // Default: `/shard` on the page's own origin, which any proxy that
166
+ // forwards WebSocket upgrades on 443 already reaches.
167
+ const host =
168
+ options.baseUrl || (typeof window !== "undefined" ? window.location.host : "localhost:4321");
169
+ return `${proto}://${host}/shard?${params.toString()}`;
170
+ };
171
+
172
+ /** Round-trip samples for every input `ack` covers. */
173
+ const timeAcks = (ack: number, at: number) => {
174
+ for (const [seq, t] of sentAt) {
175
+ if (seq > ack) break;
176
+ const sample = at - t;
177
+ rttMs = rttMs === null ? sample : rttMs + (sample - rttMs) / 8;
178
+ sentAt.delete(seq);
179
+ }
180
+ };
181
+
182
+ const scheduleReconnect = () => {
183
+ if (closed || options.autoReconnect === false) return;
184
+ reconnectTimer = setTimeout(connect, backoff);
185
+ backoff = Math.min(backoff * 2, 10_000);
186
+ };
187
+
188
+ const connect = () => {
189
+ if (closed) return;
190
+ const source = options.ticket;
191
+ if (typeof source !== "function") {
192
+ open(source);
193
+ return;
194
+ }
195
+ let ticket: string | Promise<string>;
196
+ try {
197
+ ticket = source();
198
+ } catch (e) {
199
+ dispatchError(e instanceof Error ? e : new Error(String(e)));
200
+ scheduleReconnect();
201
+ return;
202
+ }
203
+ if (typeof ticket === "string") {
204
+ open(ticket);
205
+ return;
206
+ }
207
+ ticket.then(
208
+ (t) => {
209
+ if (!closed) open(t);
210
+ },
211
+ (e) => {
212
+ dispatchError(e instanceof Error ? e : new Error(String(e)));
213
+ scheduleReconnect();
214
+ },
215
+ );
216
+ };
217
+
218
+ const open = (ticket: string | undefined) => {
219
+ const url = buildWsUrl();
220
+ try {
221
+ // Subprotocol values must be RFC 6455 tokens; encode them so spaces
222
+ // and punctuation do not break the handshake.
223
+ const protocols: string[] = [];
224
+ if (options.token) protocols.push(`bearer.${encodeURIComponent(options.token)}`);
225
+ if (ticket) protocols.push(`ticket.${encodeURIComponent(ticket)}`);
226
+ ws = protocols.length ? new WebSocket(url, protocols) : new WebSocket(url);
227
+ } catch (e) {
228
+ dispatchError(e instanceof Error ? e : new Error(String(e)));
229
+ return;
230
+ }
231
+ ws.binaryType = "arraybuffer";
232
+
233
+ ws.onopen = () => {
234
+ connected = true;
235
+ // Inputs sent on the old connection are never acknowledged on this
236
+ // one (acks restart with the connection).
237
+ sentAt.clear();
238
+ for (const h of openHandlers) h();
239
+ };
240
+
241
+ ws.onmessage = (event) => {
242
+ if (!(event.data instanceof ArrayBuffer)) return;
243
+ const at = now();
244
+ try {
245
+ const frame = parseShardFrame(event.data);
246
+ if (frame.kind === ShardFrameKind.Replication || frame.kind === ShardFrameKind.Snapshot) {
247
+ // Only the per-tick frames: a rejection can go out before its
248
+ // tick's frame is built, and would make the clock run early.
249
+ clock.observe(frame.tick, at);
250
+ lastTick = frame.tick;
251
+ lastAck = frame.ack;
252
+ timeAcks(frame.ack, at);
253
+ }
254
+ if (frame.kind === ShardFrameKind.Replication) {
255
+ let summary: ReplicationSummary;
256
+ try {
257
+ summary = entities.apply(frame.payload);
258
+ } catch (e) {
259
+ // Out of sync with the server. Reconnecting gets a full baseline.
260
+ dispatchError(e instanceof Error ? e : new Error(String(e)));
261
+ entities.clear();
262
+ ws?.close();
263
+ return;
264
+ }
265
+ // A frame applied: the connection works, so the next reconnect
266
+ // starts from the short delay again. (Resetting on open would
267
+ // retry a frame that always fails every 500 ms forever.)
268
+ backoff = options.reconnectBackoffMs ?? 500;
269
+ for (const h of replicationHandlers) h(entities, summary, frame.tick, frame.ack);
270
+ return;
271
+ }
272
+ // The replication codec byte names the frame format, not the
273
+ // shard's input codec, so only other frames set it.
274
+ codec = frame.codec;
275
+ if (frame.kind === ShardFrameKind.Snapshot) {
276
+ const snapshot = decodeShardPayload(frame.codec, frame.payload, options.decode) as TSnapshot;
277
+ backoff = options.reconnectBackoffMs ?? 500;
278
+ for (const h of snapshotHandlers) h(snapshot, frame.tick, frame.ack);
279
+ } else if (frame.kind === ShardFrameKind.InputRejected) {
280
+ const rejection = decodeShardRejection(frame.codec, frame.payload, options.decode);
281
+ if (rejection.clientSeq !== null) sentAt.delete(rejection.clientSeq);
282
+ for (const h of rejectionHandlers) h(rejection);
283
+ }
284
+ } catch (e) {
285
+ dispatchError(e instanceof Error ? e : new Error("Failed to decode shard frame"));
286
+ }
287
+ };
288
+
289
+ ws.onerror = () => {
290
+ dispatchError(new Error(`WebSocket error connecting to shard ${shardId}`));
291
+ };
292
+
293
+ ws.onclose = () => {
294
+ connected = false;
295
+ for (const h of closeHandlers) h();
296
+ scheduleReconnect();
297
+ };
298
+ };
299
+
300
+ connect();
301
+
302
+ return {
303
+ get connected() {
304
+ return connected;
305
+ },
306
+ get entities() {
307
+ return entities;
308
+ },
309
+ get clock() {
310
+ return clock;
311
+ },
312
+ get tick() {
313
+ return lastTick;
314
+ },
315
+ get ack() {
316
+ return lastAck;
317
+ },
318
+ get rttMs() {
319
+ return rttMs;
320
+ },
321
+ onSnapshot(fn) {
322
+ snapshotHandlers.push(fn);
323
+ },
324
+ onInputRejected(fn) {
325
+ rejectionHandlers.push(fn);
326
+ },
327
+ onReplication(fn) {
328
+ replicationHandlers.push(fn);
329
+ },
330
+ onError(fn) {
331
+ errorHandlers.push(fn);
332
+ },
333
+ onOpen(fn) {
334
+ openHandlers.push(fn);
335
+ },
336
+ onClose(fn) {
337
+ closeHandlers.push(fn);
338
+ },
339
+ send(input: TInput): number {
340
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
341
+ dispatchError(new Error("Cannot send: shard connection is not open"));
342
+ return 0;
343
+ }
344
+ clientSeq += 1;
345
+ ws.send(encodeShardInput(codec, input, clientSeq));
346
+ sentAt.set(clientSeq, now());
347
+ if (sentAt.size > MAX_TIMED) sentAt.delete(sentAt.keys().next().value as number);
348
+ return clientSeq;
349
+ },
350
+ close() {
351
+ closed = true;
352
+ if (reconnectTimer) clearTimeout(reconnectTimer);
353
+ if (ws) ws.close();
354
+ },
355
+ };
356
+ }
@@ -0,0 +1,137 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+
3
+ import { replicationFrame, shardFrame } from "../test/frames";
4
+ import { connectShardGame } from "./game";
5
+ import { ShardCodec, ShardFrameKind } from "./wire";
6
+
7
+ const sockets: FakeWebSocket[] = [];
8
+ class FakeWebSocket {
9
+ static OPEN = 1;
10
+ readyState = 0;
11
+ binaryType = "blob";
12
+ sent: string[] = [];
13
+ onopen: (() => void) | null = null;
14
+ onmessage: ((e: { data: ArrayBuffer }) => void) | null = null;
15
+ onerror: (() => void) | null = null;
16
+ onclose: (() => void) | null = null;
17
+ constructor(readonly url: string) {
18
+ sockets.push(this);
19
+ }
20
+ open() {
21
+ this.readyState = 1;
22
+ this.onopen?.();
23
+ }
24
+ deliver(frame: ArrayBuffer) {
25
+ this.onmessage?.({ data: frame });
26
+ }
27
+ close() {
28
+ this.readyState = 3;
29
+ this.onclose?.();
30
+ }
31
+ send(data: string) {
32
+ this.sent.push(data);
33
+ }
34
+ }
35
+
36
+ const realWebSocket = globalThis.WebSocket;
37
+ const realSetTimeout = globalThis.setTimeout;
38
+ beforeAll(() => {
39
+ globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
40
+ globalThis.setTimeout = ((fn: () => void) => {
41
+ queueMicrotask(fn);
42
+ return 0;
43
+ }) as unknown as typeof setTimeout;
44
+ });
45
+ afterAll(() => {
46
+ globalThis.WebSocket = realWebSocket;
47
+ globalThis.setTimeout = realSetTimeout;
48
+ });
49
+
50
+ function rejection(tick: number, ack: number, clientSeq: number): ArrayBuffer {
51
+ const body = new TextEncoder().encode(
52
+ JSON.stringify({ client_seq: clientSeq, code: "invalid", message: "no" }),
53
+ );
54
+ return shardFrame(ShardFrameKind.InputRejected, ShardCodec.Json, tick, ack, body);
55
+ }
56
+
57
+ test("a render loop draws entities between frames, and prediction follows acks", async () => {
58
+ let time = 0;
59
+ const game = connectShardGame<{ dx: number }>("zone", {
60
+ subscriberId: "p1",
61
+ baseUrl: "h",
62
+ tickRate: 20,
63
+ interpolationDelayMs: 100,
64
+ now: () => time,
65
+ });
66
+ const me = game.predict<number>((x, input) => x + input.dx);
67
+ let predicted = 0;
68
+ game.onReplication((table, _summary, _tick, ack) => {
69
+ const mine = table.get(1);
70
+ if (mine) predicted = me.reconcile(mine.x, ack);
71
+ });
72
+
73
+ expect(game.frame(0)).toBe(-1);
74
+ expect(game.send({ dx: 1 })).toBe(0); // not open yet
75
+
76
+ const ws = sockets[0];
77
+ expect(ws.url).toContain("v=2");
78
+ ws.open();
79
+
80
+ // Tick t is built at t * 50 ms and arrives 20 ms later. Entity 2 walks
81
+ // 1 unit per tick.
82
+ const deliver = (tick: number, ack: number, frame: Parameters<typeof replicationFrame>[2]) => {
83
+ time = tick * 50 + 20;
84
+ ws.deliver(replicationFrame(tick, ack, frame));
85
+ };
86
+ deliver(1, 0, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }, { id: 2, pos: [1, 0, 0] }] });
87
+ for (let tick = 2; tick <= 10; tick++) {
88
+ deliver(tick, 0, { update: [{ id: 2, from: [tick - 1, 0, 0], pos: [tick, 0, 0] }] });
89
+ }
90
+
91
+ // 100 ms (2 ticks) behind the newest tick, halfway between frames.
92
+ const renderTick = game.frame(10 * 50 + 20 + 25);
93
+ expect(renderTick).toBeCloseTo(8.5, 1);
94
+ expect(game.entities.get(2)?.x).toBeCloseTo(8.5, 1);
95
+ expect(game.entities.get(1)?.x).toBeCloseTo(0);
96
+ expect(game.latest.get(2)?.x).toBeCloseTo(10);
97
+
98
+ // Three inputs; the shard applies the first two, clamping the second.
99
+ const s1 = game.send({ dx: 1 });
100
+ const s2 = game.send({ dx: 1 });
101
+ const s3 = game.send({ dx: 1 });
102
+ expect([s1, s2, s3]).toEqual([1, 2, 3]);
103
+ expect(JSON.parse(ws.sent[0])).toEqual({ input: { dx: 1 }, client_seq: 1 });
104
+ deliver(11, 2, { update: [{ id: 1, from: [0, 0, 0], pos: [1.5, 0, 0] }] });
105
+ expect(predicted).toBeCloseTo(2.5);
106
+ expect(game.ack).toBe(2);
107
+ // Sent at 520 ms, acknowledged by the frame at 570 ms.
108
+ expect(game.rttMs).toBeCloseTo(50);
109
+
110
+ // The shard refuses input 3: it drops out of the prediction. A rejection
111
+ // frame moves neither the ack nor the clock.
112
+ const tickBefore = game.clock.serverTick(time);
113
+ ws.deliver(rejection(12, 0, 3));
114
+ expect(game.ack).toBe(2);
115
+ expect(game.tick).toBe(11);
116
+ expect(game.clock.serverTick(time)).toBeCloseTo(tickBefore);
117
+ deliver(12, 3, {});
118
+ expect(predicted).toBeCloseTo(1.5);
119
+ expect(game.ack).toBe(3);
120
+
121
+ // A reconnect drops inputs sent on the old connection.
122
+ game.send({ dx: 5 });
123
+ ws.close();
124
+ await new Promise((r) => realSetTimeout(r, 0));
125
+ const ws2 = sockets[1];
126
+ ws2.open();
127
+ time = 13 * 50 + 20;
128
+ ws2.deliver(replicationFrame(13, 0, { full: true, spawn: [{ id: 1, pos: [1.5, 0, 0] }] }));
129
+ expect(predicted).toBeCloseTo(1.5);
130
+ expect(me.size).toBe(0);
131
+
132
+ // Entity 2 was not in the full frame: it leaves at tick 13.
133
+ game.frame(13 * 50 + 20 + 100);
134
+ expect(game.entities.has(2)).toBe(false);
135
+ expect(game.left).toEqual([2]);
136
+ game.close();
137
+ });
package/src/game.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * A shard client for a game's render loop: the connection, the clock,
3
+ * interpolated entities, and prediction, with no framework and no
4
+ * re-render per frame.
5
+ *
6
+ * ```ts
7
+ * const game = connectShardGame("zone-1", { subscriberId, ticket, tickRate: 20 });
8
+ * const me = game.predict<Vec3>((p, input) => move(p, input));
9
+ *
10
+ * function frame(now: number) {
11
+ * game.frame(now); // places game.entities at the render tick
12
+ * for (const e of game.entities.values()) draw(e.id, e.x, e.y, e.z);
13
+ * requestAnimationFrame(frame);
14
+ * }
15
+ * requestAnimationFrame(frame);
16
+ *
17
+ * game.onReplication((table, _summary, _tick, ack) => {
18
+ * const mine = table.get(myEntityId);
19
+ * if (mine) local = me.reconcile({ x: mine.x, y: mine.y, z: mine.z }, ack);
20
+ * });
21
+ * onKey((input) => {
22
+ * // 0: not sent (the connection is down), so do not predict it.
23
+ * if (game.send(input)) local = move(local, input);
24
+ * });
25
+ * ```
26
+ */
27
+
28
+ import type { ShardClock } from "./clock";
29
+ import { connectShard, type ShardClient, type ShardConnectOptions } from "./connection";
30
+ import {
31
+ EntityInterpolator,
32
+ type InterpolatedEntity,
33
+ type InterpolationOptions,
34
+ } from "./interpolation";
35
+ import { Predictor, type PredictorOptions } from "./prediction";
36
+ import type { EntityTable, ReplicationSummary } from "./replication";
37
+ import type { ShardInputRejection } from "./wire";
38
+
39
+ export interface ShardGameOptions extends ShardConnectOptions {
40
+ /**
41
+ * How far behind the shard's estimated current tick entities are drawn,
42
+ * in ms. It must cover the time between frames plus network jitter.
43
+ * Default 100.
44
+ */
45
+ interpolationDelayMs?: number;
46
+ interpolation?: InterpolationOptions;
47
+ }
48
+
49
+ export interface ShardGame<TInput = unknown> {
50
+ /** The underlying connection. */
51
+ readonly connection: ShardClient<unknown, TInput>;
52
+ readonly clock: ShardClock;
53
+ /** Entities placed at the render tick by the last `frame` call. */
54
+ readonly entities: ReadonlyMap<number, InterpolatedEntity>;
55
+ /** Ids the last `frame` added to and removed from `entities`. */
56
+ readonly entered: readonly number[];
57
+ readonly left: readonly number[];
58
+ /** The newest state the shard sent, not interpolated. */
59
+ readonly latest: EntityTable;
60
+ readonly tick: number;
61
+ readonly ack: number;
62
+ readonly rttMs: number | null;
63
+ readonly connected: boolean;
64
+ /**
65
+ * Place `entities` for a render frame at `now` (ms, default
66
+ * `performance.now()`). Returns the render tick, or -1 before the first
67
+ * frame.
68
+ */
69
+ frame(now?: number): number;
70
+ /** Send an input; every predictor made by `predict` records it. Returns
71
+ * its sequence number, or 0 when it was not sent. */
72
+ send(input: TInput): number;
73
+ /**
74
+ * A predictor that records every input `send` sends, forgets inputs the
75
+ * shard refuses, and resets when the connection reopens. Call
76
+ * `reconcile` with the local entity's server state after each frame.
77
+ */
78
+ predict<S>(step: (state: S, input: TInput) => S, options?: PredictorOptions): Predictor<S, TInput>;
79
+ onReplication(
80
+ fn: (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void,
81
+ ): void;
82
+ onInputRejected(fn: (rejection: ShardInputRejection) => void): void;
83
+ onOpen(fn: () => void): void;
84
+ onClose(fn: () => void): void;
85
+ onError(fn: (err: Error) => void): void;
86
+ close(): void;
87
+ }
88
+
89
+ /** Connect to a replicating shard for a render loop. */
90
+ export function connectShardGame<TInput = unknown>(
91
+ shardId: string,
92
+ options: ShardGameOptions,
93
+ ): ShardGame<TInput> {
94
+ const now = options.now ?? (() => performance.now());
95
+ const connection = connectShard<unknown, TInput>(shardId, { ...options, now });
96
+ const interpolator = new EntityInterpolator(options.interpolation);
97
+ const delayMs = options.interpolationDelayMs ?? 100;
98
+ const predictors: Array<Predictor<unknown, TInput>> = [];
99
+
100
+ connection.onReplication((table, summary, tick) => interpolator.record(table, summary, tick));
101
+ connection.onInputRejected((r) => {
102
+ for (const p of predictors) p.reject(r.clientSeq);
103
+ });
104
+ connection.onOpen(() => {
105
+ for (const p of predictors) p.reset();
106
+ });
107
+
108
+ return {
109
+ connection,
110
+ get clock() {
111
+ return connection.clock;
112
+ },
113
+ get entities() {
114
+ return interpolator.entities;
115
+ },
116
+ get entered() {
117
+ return interpolator.entered;
118
+ },
119
+ get left() {
120
+ return interpolator.left;
121
+ },
122
+ get latest() {
123
+ return connection.entities;
124
+ },
125
+ get tick() {
126
+ return connection.tick;
127
+ },
128
+ get ack() {
129
+ return connection.ack;
130
+ },
131
+ get rttMs() {
132
+ return connection.rttMs;
133
+ },
134
+ get connected() {
135
+ return connection.connected;
136
+ },
137
+ frame(at = now()) {
138
+ const clock = connection.clock;
139
+ if (!clock.ready) return -1;
140
+ const renderTick = clock.serverTick(at) - delayMs / clock.tickMs;
141
+ interpolator.update(renderTick);
142
+ return renderTick;
143
+ },
144
+ send(input) {
145
+ const seq = connection.send(input);
146
+ for (const p of predictors) p.push(seq, input);
147
+ return seq;
148
+ },
149
+ predict<S>(step: (state: S, input: TInput) => S, predictorOptions?: PredictorOptions) {
150
+ const p = new Predictor<S, TInput>(step, predictorOptions);
151
+ predictors.push(p as unknown as Predictor<unknown, TInput>);
152
+ return p;
153
+ },
154
+ onReplication: (fn) => connection.onReplication(fn),
155
+ onInputRejected: (fn) => connection.onInputRejected(fn),
156
+ onOpen: (fn) => connection.onOpen(fn),
157
+ onClose: (fn) => connection.onClose(fn),
158
+ onError: (fn) => connection.onError(fn),
159
+ close: () => connection.close(),
160
+ };
161
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export * from "./wire";
2
+ export * from "./replication";
3
+ export * from "./clock";
4
+ export * from "./interpolation";
5
+ export * from "./prediction";
6
+ export * from "./connection";
7
+ export * from "./game";