@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.
- package/README.md +30 -0
- package/dist/clock.d.ts +74 -0
- package/dist/connection.d.ts +95 -0
- package/dist/game.d.ts +80 -0
- package/dist/index.d.ts +7 -0
- package/dist/interpolation.d.ts +104 -0
- package/dist/prediction.d.ts +45 -0
- package/dist/replication.d.ts +65 -0
- package/dist/wire.d.ts +62 -0
- package/package.json +25 -0
- package/src/clock.test.ts +143 -0
- package/src/clock.ts +206 -0
- package/src/connection.test.ts +80 -0
- package/src/connection.ts +356 -0
- package/src/game.test.ts +137 -0
- package/src/game.ts +161 -0
- package/src/index.ts +7 -0
- package/src/interpolation.test.ts +163 -0
- package/src/interpolation.ts +317 -0
- package/src/prediction.test.ts +41 -0
- package/src/prediction.ts +80 -0
- package/src/replication.fixtures.json +2014 -0
- package/src/replication.test.ts +64 -0
- package/src/replication.ts +241 -0
- package/src/wire.test.ts +72 -0
- package/src/wire.ts +129 -0
- package/src/world3d.e2e.test.ts +234 -0
|
@@ -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
|
+
}
|
package/src/game.test.ts
ADDED
|
@@ -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
|
+
}
|