@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
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @pylonsync/realtime
|
|
2
|
+
|
|
3
|
+
The Pylon shard client with no framework: the wire protocol, the entity
|
|
4
|
+
replication decoder, and the pieces a game's render loop needs.
|
|
5
|
+
|
|
6
|
+
- `connectShardGame(shardId, options)`: a connection that applies each
|
|
7
|
+
replication frame and places entities for a render frame
|
|
8
|
+
(`game.frame(now)`), with interpolation, a server clock estimate,
|
|
9
|
+
prediction for the local player, the input round trip, and reconnects
|
|
10
|
+
with a full frame.
|
|
11
|
+
- `connectShard(shardId, options)`: the connection alone.
|
|
12
|
+
- `EntityTable`: applies replication frames.
|
|
13
|
+
- `EntityInterpolator`, `ShardClock`, `Predictor`: the parts on their own.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { connectShardGame } from "@pylonsync/realtime";
|
|
17
|
+
|
|
18
|
+
const game = connectShardGame<Input>("zone-1", { subscriberId, ticket, tickRate: 20 });
|
|
19
|
+
|
|
20
|
+
function frame(now: number) {
|
|
21
|
+
game.frame(now);
|
|
22
|
+
for (const id of game.left) removeMesh(id);
|
|
23
|
+
for (const id of game.entered) addMesh(id);
|
|
24
|
+
for (const e of game.entities.values()) moveMesh(e.id, e.x, e.y, e.z);
|
|
25
|
+
requestAnimationFrame(frame);
|
|
26
|
+
}
|
|
27
|
+
requestAnimationFrame(frame);
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
See the shards guide: https://docs.pylonsync.com/concepts/shards
|
package/dist/clock.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An estimate of the shard's current tick, from the ticks in the frames it
|
|
3
|
+
* sends and when they arrive.
|
|
4
|
+
*
|
|
5
|
+
* Each frame carries the tick it was built on. The frame that arrived
|
|
6
|
+
* soonest after it was built (lowest delay) anchors the estimate, so
|
|
7
|
+
* network jitter only ever makes a frame look late. The tick length is the
|
|
8
|
+
* shard's tick rate when the client knows it, or else the median slope of
|
|
9
|
+
* arrival time against tick.
|
|
10
|
+
*
|
|
11
|
+
* The estimate is continuous: when a new frame moves it, the change is
|
|
12
|
+
* spread over time instead of jumping (from `slew` of real time for small
|
|
13
|
+
* changes up to all of it). A step forward larger than `snapMs` applies at
|
|
14
|
+
* once. A step back larger than that applies only as far as the newest
|
|
15
|
+
* tick received, so it never goes back past a tick a frame carried.
|
|
16
|
+
*
|
|
17
|
+
* After three frames in a row arrive more than `snapMs` late and spaced
|
|
18
|
+
* like ticks (a shard that stalled), the older frames are dropped and the
|
|
19
|
+
* clock follows the new schedule. Frames that arrive together (a network
|
|
20
|
+
* burst) do not count.
|
|
21
|
+
*/
|
|
22
|
+
export interface ShardClockOptions {
|
|
23
|
+
/** The shard's tick rate in ticks per second, when the client knows it. */
|
|
24
|
+
tickRate?: number;
|
|
25
|
+
/** Frames the estimate uses. Default 64. */
|
|
26
|
+
window?: number;
|
|
27
|
+
/** The least fraction of real time the estimate speeds up or slows
|
|
28
|
+
* down by to absorb a correction. Default 0.05. */
|
|
29
|
+
slew?: number;
|
|
30
|
+
/** A step forward larger than this (ms) applies at once, and a
|
|
31
|
+
* correction this large or larger runs at full rate. Default 250. */
|
|
32
|
+
snapMs?: number;
|
|
33
|
+
}
|
|
34
|
+
export declare class ShardClock {
|
|
35
|
+
private readonly fixedTickMs;
|
|
36
|
+
private readonly window;
|
|
37
|
+
private readonly slew;
|
|
38
|
+
private readonly snapMs;
|
|
39
|
+
private samples;
|
|
40
|
+
private measuredTickMs;
|
|
41
|
+
/** The newest tick seen, and the estimated arrival time of a frame for
|
|
42
|
+
* it with the lowest delay seen. */
|
|
43
|
+
private anchorTick;
|
|
44
|
+
private anchorAt;
|
|
45
|
+
/** Ticks the displayed estimate is ahead of the raw one; decays to 0. */
|
|
46
|
+
private offset;
|
|
47
|
+
private slewedTo;
|
|
48
|
+
/** Frames in a row that arrived more than `snapMs` after the anchor
|
|
49
|
+
* predicted. */
|
|
50
|
+
private late;
|
|
51
|
+
constructor(options?: ShardClockOptions);
|
|
52
|
+
/** True once a frame has arrived. */
|
|
53
|
+
get ready(): boolean;
|
|
54
|
+
/** The newest tick a frame carried, or -1. */
|
|
55
|
+
get latestTick(): number;
|
|
56
|
+
/** Milliseconds per tick: the configured rate, or the measured one. */
|
|
57
|
+
get tickMs(): number;
|
|
58
|
+
/** Forget everything (a new shard). */
|
|
59
|
+
reset(): void;
|
|
60
|
+
/** Record a frame for `tick` that arrived at `at` (ms, monotonic). */
|
|
61
|
+
observe(tick: number, at: number): void;
|
|
62
|
+
/**
|
|
63
|
+
* The estimated tick the shard is on at `now` (ms, same clock as
|
|
64
|
+
* `observe`), with a fraction. Before the first frame, -1.
|
|
65
|
+
*/
|
|
66
|
+
serverTick(now: number): number;
|
|
67
|
+
private raw;
|
|
68
|
+
/**
|
|
69
|
+
* The median slope of arrival time against tick over every pair of
|
|
70
|
+
* samples (Theil-Sen): frames that arrive together in a burst are a
|
|
71
|
+
* minority of pairs and do not pull it.
|
|
72
|
+
*/
|
|
73
|
+
private fitTickMs;
|
|
74
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
import { ShardClock } from "./clock";
|
|
8
|
+
import { EntityTable, type ReplicationSummary } from "./replication";
|
|
9
|
+
import { type ShardInputRejection, type ShardPayloadDecoder } from "./wire";
|
|
10
|
+
export interface ShardConnectOptions {
|
|
11
|
+
/** Subscriber ID (usually the logged-in user ID). Required for multiplayer. */
|
|
12
|
+
subscriberId: string;
|
|
13
|
+
/**
|
|
14
|
+
* Auth token. Sent as a `bearer.<token>` WebSocket subprotocol, which
|
|
15
|
+
* keeps it out of URLs (proxy logs, devtools, and error telemetry record
|
|
16
|
+
* URLs, not subprotocols).
|
|
17
|
+
*/
|
|
18
|
+
token?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Shard ticket from a server function (`ctx.shards.ticket(...)`). Sent as
|
|
21
|
+
* a `ticket.<ticket>` WebSocket subprotocol. The shard checks it names
|
|
22
|
+
* this shard and `subscriberId`, and passes its claims to the game's
|
|
23
|
+
* authorization hooks.
|
|
24
|
+
*
|
|
25
|
+
* Tickets expire. Pass a function to get a new one for each connection
|
|
26
|
+
* attempt, so a reconnect after the expiry still gets in.
|
|
27
|
+
*/
|
|
28
|
+
ticket?: string | (() => string | Promise<string>);
|
|
29
|
+
/** Host (and port) of the Pylon server. Defaults to `window.location.host`. */
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Connect to the dedicated shard port instead of `/shard` on the main
|
|
33
|
+
* port (the dedicated port is the HTTP port + 3, e.g. 4324).
|
|
34
|
+
*/
|
|
35
|
+
wsPort?: number;
|
|
36
|
+
/** Explicit WebSocket URL. Overrides baseUrl/wsPort. */
|
|
37
|
+
wsUrl?: string;
|
|
38
|
+
/** Reconnect on unexpected close (default: true). */
|
|
39
|
+
autoReconnect?: boolean;
|
|
40
|
+
/** First reconnect delay in ms (default 500; doubles to at most 10 000). */
|
|
41
|
+
reconnectBackoffMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Decoder for a payload codec the client does not know: bincode (`2`) or
|
|
44
|
+
* a game's own codec (`3`). JSON and MessagePack are built in.
|
|
45
|
+
*/
|
|
46
|
+
decode?: ShardPayloadDecoder;
|
|
47
|
+
/** The shard's tick rate, when known; otherwise the clock measures it. */
|
|
48
|
+
tickRate?: number;
|
|
49
|
+
/** Monotonic time in ms. Default `performance.now()`. */
|
|
50
|
+
now?: () => number;
|
|
51
|
+
}
|
|
52
|
+
export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
|
|
53
|
+
/** `ack` is the highest `send()` sequence number the shard has processed. */
|
|
54
|
+
onSnapshot: (fn: (snapshot: TSnapshot, tick: number, ack: number) => void) => void;
|
|
55
|
+
/** Called when the shard refuses an input (see `ShardInputRejection.code`). */
|
|
56
|
+
onInputRejected: (fn: (rejection: ShardInputRejection) => void) => void;
|
|
57
|
+
/**
|
|
58
|
+
* For a shard that replicates entities: called after each frame is
|
|
59
|
+
* applied to `entities`, with what it changed.
|
|
60
|
+
*/
|
|
61
|
+
onReplication: (fn: (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void) => void;
|
|
62
|
+
/**
|
|
63
|
+
* The entities a replicating shard has sent this client. Read it each
|
|
64
|
+
* frame (a render loop); it changes in place as frames arrive.
|
|
65
|
+
*/
|
|
66
|
+
readonly entities: EntityTable;
|
|
67
|
+
/** The shard's current tick, estimated from frame arrivals. */
|
|
68
|
+
readonly clock: ShardClock;
|
|
69
|
+
/** The tick of the last frame, or -1. */
|
|
70
|
+
readonly tick: number;
|
|
71
|
+
/** The ack of the last frame. */
|
|
72
|
+
readonly ack: number;
|
|
73
|
+
/**
|
|
74
|
+
* Smoothed time from sending an input to the first frame that
|
|
75
|
+
* acknowledges it (ms), or null before one has. It includes the wait for
|
|
76
|
+
* the shard's next tick.
|
|
77
|
+
*/
|
|
78
|
+
readonly rttMs: number | null;
|
|
79
|
+
onError: (fn: (err: Error) => void) => void;
|
|
80
|
+
onOpen: (fn: () => void) => void;
|
|
81
|
+
onClose: (fn: () => void) => void;
|
|
82
|
+
/**
|
|
83
|
+
* Send an input. Returns its sequence number, which later frames
|
|
84
|
+
* acknowledge, or 0 when the connection is not open (the input is not
|
|
85
|
+
* sent, and `onError` hears why).
|
|
86
|
+
*/
|
|
87
|
+
send: (input: TInput) => number;
|
|
88
|
+
close: () => void;
|
|
89
|
+
readonly connected: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Connect to a shard without React. Returns a client you can wire into any
|
|
93
|
+
* framework or render loop.
|
|
94
|
+
*/
|
|
95
|
+
export declare function connectShard<TSnapshot = unknown, TInput = unknown>(shardId: string, options: ShardConnectOptions): ShardClient<TSnapshot, TInput>;
|
package/dist/game.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
import type { ShardClock } from "./clock";
|
|
28
|
+
import { type ShardClient, type ShardConnectOptions } from "./connection";
|
|
29
|
+
import { type InterpolatedEntity, type InterpolationOptions } from "./interpolation";
|
|
30
|
+
import { Predictor, type PredictorOptions } from "./prediction";
|
|
31
|
+
import type { EntityTable, ReplicationSummary } from "./replication";
|
|
32
|
+
import type { ShardInputRejection } from "./wire";
|
|
33
|
+
export interface ShardGameOptions extends ShardConnectOptions {
|
|
34
|
+
/**
|
|
35
|
+
* How far behind the shard's estimated current tick entities are drawn,
|
|
36
|
+
* in ms. It must cover the time between frames plus network jitter.
|
|
37
|
+
* Default 100.
|
|
38
|
+
*/
|
|
39
|
+
interpolationDelayMs?: number;
|
|
40
|
+
interpolation?: InterpolationOptions;
|
|
41
|
+
}
|
|
42
|
+
export interface ShardGame<TInput = unknown> {
|
|
43
|
+
/** The underlying connection. */
|
|
44
|
+
readonly connection: ShardClient<unknown, TInput>;
|
|
45
|
+
readonly clock: ShardClock;
|
|
46
|
+
/** Entities placed at the render tick by the last `frame` call. */
|
|
47
|
+
readonly entities: ReadonlyMap<number, InterpolatedEntity>;
|
|
48
|
+
/** Ids the last `frame` added to and removed from `entities`. */
|
|
49
|
+
readonly entered: readonly number[];
|
|
50
|
+
readonly left: readonly number[];
|
|
51
|
+
/** The newest state the shard sent, not interpolated. */
|
|
52
|
+
readonly latest: EntityTable;
|
|
53
|
+
readonly tick: number;
|
|
54
|
+
readonly ack: number;
|
|
55
|
+
readonly rttMs: number | null;
|
|
56
|
+
readonly connected: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Place `entities` for a render frame at `now` (ms, default
|
|
59
|
+
* `performance.now()`). Returns the render tick, or -1 before the first
|
|
60
|
+
* frame.
|
|
61
|
+
*/
|
|
62
|
+
frame(now?: number): number;
|
|
63
|
+
/** Send an input; every predictor made by `predict` records it. Returns
|
|
64
|
+
* its sequence number, or 0 when it was not sent. */
|
|
65
|
+
send(input: TInput): number;
|
|
66
|
+
/**
|
|
67
|
+
* A predictor that records every input `send` sends, forgets inputs the
|
|
68
|
+
* shard refuses, and resets when the connection reopens. Call
|
|
69
|
+
* `reconcile` with the local entity's server state after each frame.
|
|
70
|
+
*/
|
|
71
|
+
predict<S>(step: (state: S, input: TInput) => S, options?: PredictorOptions): Predictor<S, TInput>;
|
|
72
|
+
onReplication(fn: (entities: EntityTable, summary: ReplicationSummary, tick: number, ack: number) => void): void;
|
|
73
|
+
onInputRejected(fn: (rejection: ShardInputRejection) => void): void;
|
|
74
|
+
onOpen(fn: () => void): void;
|
|
75
|
+
onClose(fn: () => void): void;
|
|
76
|
+
onError(fn: (err: Error) => void): void;
|
|
77
|
+
close(): void;
|
|
78
|
+
}
|
|
79
|
+
/** Connect to a replicating shard for a render loop. */
|
|
80
|
+
export declare function connectShardGame<TInput = unknown>(shardId: string, options: ShardGameOptions): ShardGame<TInput>;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity interpolation for render loops.
|
|
3
|
+
*
|
|
4
|
+
* Record every replication frame with the tick it was built on; each render
|
|
5
|
+
* frame, call `update` with a tick a little behind the server's (see
|
|
6
|
+
* `ShardClock`). Each entity is then drawn between the two samples around
|
|
7
|
+
* that tick, so motion is smooth even though frames arrive at the tick rate
|
|
8
|
+
* and with jitter. Spawns, despawns, and component changes appear at the
|
|
9
|
+
* tick they happened, in step with the positions.
|
|
10
|
+
*/
|
|
11
|
+
import type { EntityTable, ReplicationSummary } from "./replication";
|
|
12
|
+
export interface InterpolationOptions {
|
|
13
|
+
/**
|
|
14
|
+
* A move longer than this (world units) between two samples is a
|
|
15
|
+
* teleport: the entity jumps instead of sliding. Default: no limit.
|
|
16
|
+
*/
|
|
17
|
+
snapDistance?: number;
|
|
18
|
+
/**
|
|
19
|
+
* Treat an entity that sent no update as not moving, so its next move
|
|
20
|
+
* starts from the tick before it arrives. Default true. Set false for a
|
|
21
|
+
* shard with a byte budget (`max_bytes_per_tick`), where updates can
|
|
22
|
+
* wait for later ticks.
|
|
23
|
+
*/
|
|
24
|
+
holdWhenQuiet?: boolean;
|
|
25
|
+
/** Samples kept per entity. Default 32. */
|
|
26
|
+
maxSamples?: number;
|
|
27
|
+
}
|
|
28
|
+
/** An entity's state at one tick. */
|
|
29
|
+
export interface EntitySample {
|
|
30
|
+
readonly tick: number;
|
|
31
|
+
readonly x: number;
|
|
32
|
+
readonly y: number;
|
|
33
|
+
readonly z: number;
|
|
34
|
+
/** Component id to bytes. Shared between samples; do not change it. */
|
|
35
|
+
readonly components: ReadonlyMap<number, Uint8Array>;
|
|
36
|
+
}
|
|
37
|
+
/** One entity as drawn at the render tick. The object is reused. */
|
|
38
|
+
export interface InterpolatedEntity {
|
|
39
|
+
readonly id: number;
|
|
40
|
+
x: number;
|
|
41
|
+
y: number;
|
|
42
|
+
z: number;
|
|
43
|
+
/** Components as of the sample at or before the render tick. */
|
|
44
|
+
components: ReadonlyMap<number, Uint8Array>;
|
|
45
|
+
/**
|
|
46
|
+
* The samples around the render tick and the fraction between them, to
|
|
47
|
+
* interpolate game data kept in components (a heading, a health bar).
|
|
48
|
+
* `from` and `to` are the same sample when there is no later one.
|
|
49
|
+
*/
|
|
50
|
+
from: EntitySample;
|
|
51
|
+
to: EntitySample;
|
|
52
|
+
t: number;
|
|
53
|
+
}
|
|
54
|
+
export declare class EntityInterpolator {
|
|
55
|
+
/** Entities that exist at the last `update`'s render tick. */
|
|
56
|
+
readonly entities: Map<number, InterpolatedEntity>;
|
|
57
|
+
/**
|
|
58
|
+
* Ids the last `update` added to `entities`. An id in both `left` and
|
|
59
|
+
* `entered` names a new entity that reused the id: rebuild what you
|
|
60
|
+
* drew for it.
|
|
61
|
+
*/
|
|
62
|
+
readonly entered: number[];
|
|
63
|
+
/** Ids the last `update` removed from `entities`. */
|
|
64
|
+
readonly left: number[];
|
|
65
|
+
private readonly lives;
|
|
66
|
+
/** The life each entry in `entities` shows. */
|
|
67
|
+
private readonly shown;
|
|
68
|
+
private readonly snapDistance;
|
|
69
|
+
private readonly holdWhenQuiet;
|
|
70
|
+
private readonly maxSamples;
|
|
71
|
+
private lastTick;
|
|
72
|
+
/** The newest tick the last `update` drew; a render tick never goes
|
|
73
|
+
* below it. */
|
|
74
|
+
private floor;
|
|
75
|
+
/** Lives that ended, oldest first, so `record` can drop them when
|
|
76
|
+
* `update` does not run (a hidden tab keeps receiving frames). */
|
|
77
|
+
private ended;
|
|
78
|
+
constructor(options?: InterpolationOptions);
|
|
79
|
+
/** The newest tick recorded, or -1. */
|
|
80
|
+
get latestTick(): number;
|
|
81
|
+
/** Forget everything. The next `update` removes every entity. */
|
|
82
|
+
clear(): void;
|
|
83
|
+
/**
|
|
84
|
+
* Record the table after a replication frame for `tick` applied, with the
|
|
85
|
+
* summary `EntityTable.apply` returned.
|
|
86
|
+
*/
|
|
87
|
+
record(table: EntityTable, summary: ReplicationSummary, tick: number): void;
|
|
88
|
+
/**
|
|
89
|
+
* Place every entity at `renderTick` (fractional). A render tick below
|
|
90
|
+
* one already drawn is raised to it, so nothing drawn goes back in time.
|
|
91
|
+
*/
|
|
92
|
+
update(renderTick: number): void;
|
|
93
|
+
private end;
|
|
94
|
+
/**
|
|
95
|
+
* Forget lives that ended long before `tick`. `update` removes them
|
|
96
|
+
* when it runs; this bounds them when it does not.
|
|
97
|
+
*/
|
|
98
|
+
private dropEnded;
|
|
99
|
+
private openLife;
|
|
100
|
+
private spawn;
|
|
101
|
+
private push;
|
|
102
|
+
private place;
|
|
103
|
+
private remove;
|
|
104
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side prediction for the local player.
|
|
3
|
+
*
|
|
4
|
+
* Apply each input locally as it is sent, and keep it until the shard
|
|
5
|
+
* acknowledges it. When a frame arrives with the server's state and its
|
|
6
|
+
* `ack` (the highest input sequence number the shard has processed), start
|
|
7
|
+
* from the server's state and apply the inputs it has not processed yet.
|
|
8
|
+
* The result is where the local player is after every input, corrected by
|
|
9
|
+
* whatever the server decided.
|
|
10
|
+
*
|
|
11
|
+
* `step` must be deterministic and must match what the shard does with an
|
|
12
|
+
* input closely enough that the replayed state agrees with the server's.
|
|
13
|
+
* It must not change `state` in place; return a new one.
|
|
14
|
+
*/
|
|
15
|
+
export interface PredictorOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Inputs kept while waiting for an ack. Past this the oldest are
|
|
18
|
+
* dropped (the prediction then misses them). Default 256.
|
|
19
|
+
*/
|
|
20
|
+
maxPending?: number;
|
|
21
|
+
}
|
|
22
|
+
export declare class Predictor<S, I> {
|
|
23
|
+
private readonly step;
|
|
24
|
+
private pending;
|
|
25
|
+
private readonly maxPending;
|
|
26
|
+
constructor(step: (state: S, input: I) => S, options?: PredictorOptions);
|
|
27
|
+
/** Inputs sent and not yet acknowledged. */
|
|
28
|
+
get size(): number;
|
|
29
|
+
/** Record an input sent with sequence number `seq`. A `seq` of 0 (not
|
|
30
|
+
* sent) is ignored. */
|
|
31
|
+
push(seq: number, input: I): void;
|
|
32
|
+
/** The shard refused input `seq`: it will never apply. */
|
|
33
|
+
reject(seq: number | null): void;
|
|
34
|
+
/**
|
|
35
|
+
* The predicted state: `server` (the shard's state as of a frame) with
|
|
36
|
+
* every input after `ack` applied. Inputs up to `ack` are dropped.
|
|
37
|
+
*/
|
|
38
|
+
reconcile(server: S, ack: number): S;
|
|
39
|
+
/**
|
|
40
|
+
* Drop every pending input. Call it when the connection reopens: inputs
|
|
41
|
+
* sent on the old connection either applied before it closed (and are
|
|
42
|
+
* in the server's state) or never will.
|
|
43
|
+
*/
|
|
44
|
+
reset(): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity replication frames (see `pylon_replication::frame` in Rust).
|
|
3
|
+
*
|
|
4
|
+
* A shard that replicates entities sends each subscriber, per tick, the
|
|
5
|
+
* entities that left its view (despawn), came into view (spawn, full
|
|
6
|
+
* state), or changed (update: only the axes that moved, as a quantized
|
|
7
|
+
* difference, and the components that changed or were removed).
|
|
8
|
+
* {@link EntityTable} applies them; after each frame it holds what the
|
|
9
|
+
* server says this subscriber sees.
|
|
10
|
+
*
|
|
11
|
+
* ```text
|
|
12
|
+
* u8 version (1)
|
|
13
|
+
* u8 flags: bit 0 FULL: clear the table first
|
|
14
|
+
* f32 LE precision
|
|
15
|
+
* varint despawn count, ids (first absolute, then differences; ascending)
|
|
16
|
+
* varint spawn count, per entity: id, x, y, z (zigzag, quantized), components
|
|
17
|
+
* varint update count, per entity: id, u8 mask (1 x, 2 y, 4 z, 8 components),
|
|
18
|
+
* changed axes (zigzag differences), components if bit 8
|
|
19
|
+
* components: varint count, per component: u8 id, varint (length + 1),
|
|
20
|
+
* bytes; length field 0 means removed
|
|
21
|
+
* ```
|
|
22
|
+
*
|
|
23
|
+
* JavaScript numbers hold integers exactly up to 2^53. Entity ids and
|
|
24
|
+
* quantized positions must stay below that; a frame that exceeds it is
|
|
25
|
+
* refused rather than rounded.
|
|
26
|
+
*/
|
|
27
|
+
export declare const REPLICATION_VERSION = 1;
|
|
28
|
+
export declare class ReplicationError extends Error {
|
|
29
|
+
constructor(message: string);
|
|
30
|
+
}
|
|
31
|
+
/** One entity as the client sees it. */
|
|
32
|
+
export interface ReplicatedEntity {
|
|
33
|
+
readonly id: number;
|
|
34
|
+
/** Quantized position; `x/y/z` are these times the frame precision. */
|
|
35
|
+
qx: number;
|
|
36
|
+
qy: number;
|
|
37
|
+
qz: number;
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
z: number;
|
|
41
|
+
/** Component id to bytes, in the game's own encoding. */
|
|
42
|
+
components: Map<number, Uint8Array>;
|
|
43
|
+
}
|
|
44
|
+
/** What one frame did. */
|
|
45
|
+
export interface ReplicationSummary {
|
|
46
|
+
full: boolean;
|
|
47
|
+
spawned: number[];
|
|
48
|
+
updated: number[];
|
|
49
|
+
despawned: number[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The entities one subscriber has been told about. Apply every
|
|
53
|
+
* replication frame in order; a frame that fails to apply means the
|
|
54
|
+
* connection is out of sync and should be reopened (the server then sends
|
|
55
|
+
* a full frame).
|
|
56
|
+
*/
|
|
57
|
+
export declare class EntityTable {
|
|
58
|
+
readonly entities: Map<number, ReplicatedEntity>;
|
|
59
|
+
/** World units per quantization step, from the last frame. */
|
|
60
|
+
precision: number;
|
|
61
|
+
get size(): number;
|
|
62
|
+
get(id: number): ReplicatedEntity | undefined;
|
|
63
|
+
clear(): void;
|
|
64
|
+
apply(frame: Uint8Array): ReplicationSummary;
|
|
65
|
+
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shard wire protocol, version 2 (see `pylon_realtime::wire` in Rust).
|
|
3
|
+
*
|
|
4
|
+
* A client asks for it with `?v=2` on the shard WebSocket URL. Each server
|
|
5
|
+
* message is a binary frame with an 18-byte header:
|
|
6
|
+
*
|
|
7
|
+
* 0 1 frame kind: 1 snapshot, 2 input rejected, 3 entity replication
|
|
8
|
+
* 1 1 codec: 0 JSON, 1 MessagePack, 2 bincode, 3 custom, 4 replication
|
|
9
|
+
* 2 8 tick (u64 big-endian)
|
|
10
|
+
* 10 8 ack: highest client_seq the shard processed for this subscriber (0 = none)
|
|
11
|
+
* 18 .. payload in the codec
|
|
12
|
+
*
|
|
13
|
+
* Inputs go up as `{ input, client_seq }`: JSON in a text frame, or the
|
|
14
|
+
* shard's codec in a binary frame.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SHARD_PROTOCOL_VERSION = 2;
|
|
17
|
+
export declare const SHARD_HEADER_LEN = 18;
|
|
18
|
+
export declare const ShardFrameKind: {
|
|
19
|
+
readonly Snapshot: 1;
|
|
20
|
+
readonly InputRejected: 2;
|
|
21
|
+
/** An entity replication frame: apply it to an `EntityTable`. */
|
|
22
|
+
readonly Replication: 3;
|
|
23
|
+
};
|
|
24
|
+
export declare const ShardCodec: {
|
|
25
|
+
readonly Json: 0;
|
|
26
|
+
readonly MessagePack: 1;
|
|
27
|
+
readonly Bincode: 2;
|
|
28
|
+
readonly Custom: 3;
|
|
29
|
+
/** The replication frame format (frame kind 3). */
|
|
30
|
+
readonly Replication: 4;
|
|
31
|
+
};
|
|
32
|
+
export interface ShardFrame {
|
|
33
|
+
kind: number;
|
|
34
|
+
codec: number;
|
|
35
|
+
tick: number;
|
|
36
|
+
ack: number;
|
|
37
|
+
payload: Uint8Array;
|
|
38
|
+
}
|
|
39
|
+
/** Why an input did not take effect. */
|
|
40
|
+
export interface ShardInputRejection {
|
|
41
|
+
clientSeq: number | null;
|
|
42
|
+
/** `unauthorized`, `rate_limited`, `queue_full`, `invalid`, `stopped`, or `apply_failed`. */
|
|
43
|
+
code: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Decode a payload in a codec the JS client does not know (bincode, or a
|
|
48
|
+
* game's own codec `3`).
|
|
49
|
+
*/
|
|
50
|
+
export type ShardPayloadDecoder = (payload: Uint8Array, codec: number) => unknown;
|
|
51
|
+
/** Split a version 2 frame into its header fields and payload. */
|
|
52
|
+
export declare function parseShardFrame(data: ArrayBuffer): ShardFrame;
|
|
53
|
+
/** Decode a payload. JSON and MessagePack are built in. */
|
|
54
|
+
export declare function decodeShardPayload(codec: number, payload: Uint8Array, custom?: ShardPayloadDecoder): unknown;
|
|
55
|
+
/** The payload of an input-rejected frame, with camelCase fields. */
|
|
56
|
+
export declare function decodeShardRejection(codec: number, payload: Uint8Array, custom?: ShardPayloadDecoder): ShardInputRejection;
|
|
57
|
+
/**
|
|
58
|
+
* Encode an input envelope. MessagePack shards get a binary frame; every
|
|
59
|
+
* other codec (and a connection that has not seen a frame yet) gets JSON
|
|
60
|
+
* text, which the server always accepts.
|
|
61
|
+
*/
|
|
62
|
+
export declare function encodeShardInput(codec: number | null, input: unknown, clientSeq: number): string | Uint8Array;
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pylonsync/realtime",
|
|
3
|
+
"publishConfig": {
|
|
4
|
+
"access": "public"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.14.0",
|
|
7
|
+
"description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./src/index.ts",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.build.json",
|
|
13
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
14
|
+
"test": "bun test",
|
|
15
|
+
"prepack": "bun run build"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@msgpack/msgpack": "^3.1.3"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md"
|
|
24
|
+
]
|
|
25
|
+
}
|