@pylonsync/realtime 0.22.12 → 0.22.14

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.
@@ -124,7 +124,12 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
124
124
  * acknowledge, or 0 when the connection is not open (the input is not
125
125
  * sent, and `onError` hears why).
126
126
  */
127
- send: (input: TInput) => number;
127
+ /**
128
+ * `viewTick`: the tick (fractional) the client was drawing when the
129
+ * player acted, for a shard with lag compensation. `connectShardGame`
130
+ * sets it from the last `frame`.
131
+ */
132
+ send: (input: TInput, viewTick?: number) => number;
128
133
  close: () => void;
129
134
  readonly connected: boolean;
130
135
  /** The transport of the open (or last) connection, or null before one opened. */
package/dist/game.d.ts CHANGED
@@ -65,8 +65,11 @@ export interface ShardGame<TInput = unknown> {
65
65
  * frame.
66
66
  */
67
67
  frame(now?: number): number;
68
- /** Send an input; every predictor made by `predict` records it. Returns
69
- * its sequence number, or 0 when it was not sent. */
68
+ /**
69
+ * Send an input; every predictor made by `predict` records it. The input
70
+ * carries the tick the last `frame` drew, for a shard with lag
71
+ * compensation. Returns its sequence number, or 0 when it was not sent.
72
+ */
70
73
  send(input: TInput): number;
71
74
  /**
72
75
  * A predictor that records every input `send` sends, forgets inputs the
@@ -72,12 +72,20 @@ export declare class EntityInterpolator {
72
72
  /** The newest tick the last `update` drew; a render tick never goes
73
73
  * below it. */
74
74
  private floor;
75
+ private drawn;
75
76
  /** Lives that ended, oldest first, so `record` can drop them when
76
77
  * `update` does not run (a hidden tab keeps receiving frames). */
77
78
  private ended;
78
79
  constructor(options?: InterpolationOptions);
79
80
  /** The newest tick recorded, or -1. */
80
81
  get latestTick(): number;
82
+ /**
83
+ * The tick the last `update` drew: its render tick, raised to what an
84
+ * earlier update drew and capped at the newest tick received, or -1 before
85
+ * the first update. This is the tick the player saw: the view tick for lag
86
+ * compensation.
87
+ */
88
+ get drawnTick(): number;
81
89
  /** Forget everything. The next `update` removes every entity. */
82
90
  clear(): void;
83
91
  /**
package/dist/wire.d.ts CHANGED
@@ -87,7 +87,7 @@ export declare function decodeShardRejection(codec: number, payload: Uint8Array,
87
87
  * other codec (and a connection that has not seen a frame yet) gets JSON
88
88
  * text, which the server always accepts.
89
89
  */
90
- export declare function encodeShardInput(codec: number | null, input: unknown, clientSeq: number): string | Uint8Array;
90
+ export declare function encodeShardInput(codec: number | null, input: unknown, clientSeq: number, viewTick?: number): string | Uint8Array;
91
91
  /** Acks one message may carry (the server refuses more). */
92
92
  export declare const MAX_ACKS_PER_MESSAGE = 512;
93
93
  /**
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.22.12",
6
+ "version": "0.22.14",
7
7
  "description": "Pylon realtime shard client pieces with no framework: the wire protocol, codecs, and the entity replication decoder and table.",
8
8
  "type": "module",
9
9
  "main": "./src/index.ts",
package/src/connection.ts CHANGED
@@ -147,7 +147,12 @@ export interface ShardClient<TSnapshot = unknown, TInput = unknown> {
147
147
  * acknowledge, or 0 when the connection is not open (the input is not
148
148
  * sent, and `onError` hears why).
149
149
  */
150
- send: (input: TInput) => number;
150
+ /**
151
+ * `viewTick`: the tick (fractional) the client was drawing when the
152
+ * player acted, for a shard with lag compensation. `connectShardGame`
153
+ * sets it from the last `frame`.
154
+ */
155
+ send: (input: TInput, viewTick?: number) => number;
151
156
  close: () => void;
152
157
  readonly connected: boolean;
153
158
  /** The transport of the open (or last) connection, or null before one opened. */
@@ -1041,13 +1046,13 @@ export function connectShard<TSnapshot = unknown, TInput = unknown>(
1041
1046
  onClose(fn) {
1042
1047
  closeHandlers.push(fn);
1043
1048
  },
1044
- send(input: TInput): number {
1049
+ send(input: TInput, viewTick?: number): number {
1045
1050
  if (!link || !connected || !link.open) {
1046
1051
  dispatchError(new Error("Cannot send: shard connection is not open"));
1047
1052
  return 0;
1048
1053
  }
1049
1054
  clientSeq += 1;
1050
- link.send(encodeShardInput(codec, input, clientSeq));
1055
+ link.send(encodeShardInput(codec, input, clientSeq, viewTick));
1051
1056
  sentAt.set(clientSeq, now());
1052
1057
  if (sentAt.size > MAX_TIMED) sentAt.delete(sentAt.keys().next().value as number);
1053
1058
  return clientSeq;
package/src/game.test.ts CHANGED
@@ -100,7 +100,8 @@ test("a render loop draws entities between frames, and prediction follows acks",
100
100
  const s2 = game.send({ dx: 1 });
101
101
  const s3 = game.send({ dx: 1 });
102
102
  expect([s1, s2, s3]).toEqual([1, 2, 3]);
103
- expect(JSON.parse(ws.sent[0])).toEqual({ input: { dx: 1 }, client_seq: 1 });
103
+ // Each input carries the tick the last frame drew, for lag compensation.
104
+ expect(JSON.parse(ws.sent[0])).toEqual({ input: { dx: 1 }, client_seq: 1, view_tick: renderTick });
104
105
  deliver(11, 2, { update: [{ id: 1, from: [0, 0, 0], pos: [1.5, 0, 0] }] });
105
106
  expect(predicted).toBeCloseTo(2.5);
106
107
  expect(game.ack).toBe(2);
@@ -135,3 +136,13 @@ test("a render loop draws entities between frames, and prediction follows acks",
135
136
  expect(game.left).toEqual([2]);
136
137
  game.close();
137
138
  });
139
+
140
+ test("an input sent before the first frame carries no view tick", () => {
141
+ const game = connectShardGame<{ dx: number }>("zone-nv", { subscriberId: "p1", baseUrl: "h", now: () => 0 });
142
+ const ws = sockets[sockets.length - 1];
143
+ ws.open();
144
+ ws.deliver(replicationFrame(1, 0, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] }));
145
+ expect(game.send({ dx: 1 })).toBe(1);
146
+ expect(JSON.parse(ws.sent[0])).toEqual({ input: { dx: 1 }, client_seq: 1 });
147
+ game.close();
148
+ });
package/src/game.ts CHANGED
@@ -77,8 +77,11 @@ export interface ShardGame<TInput = unknown> {
77
77
  * frame.
78
78
  */
79
79
  frame(now?: number): number;
80
- /** Send an input; every predictor made by `predict` records it. Returns
81
- * its sequence number, or 0 when it was not sent. */
80
+ /**
81
+ * Send an input; every predictor made by `predict` records it. The input
82
+ * carries the tick the last `frame` drew, for a shard with lag
83
+ * compensation. Returns its sequence number, or 0 when it was not sent.
84
+ */
82
85
  send(input: TInput): number;
83
86
  /**
84
87
  * A predictor that records every input `send` sends, forgets inputs the
@@ -160,7 +163,8 @@ export function connectShardGame<TInput = unknown>(
160
163
  return renderTick;
161
164
  },
162
165
  send(input) {
163
- const seq = connection.send(input);
166
+ const drawn = interpolator.drawnTick;
167
+ const seq = connection.send(input, drawn >= 0 ? drawn : undefined);
164
168
  for (const p of predictors) p.push(seq, input);
165
169
  return seq;
166
170
  },
@@ -21,6 +21,24 @@ function harness(options?: InterpolationOptions) {
21
21
  }
22
22
 
23
23
  describe("EntityInterpolator", () => {
24
+ test("drawnTick is the tick an update placed entities at, never going back", () => {
25
+ const h = harness();
26
+ expect(h.interp.drawnTick).toBe(-1);
27
+ h.frame(1, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
28
+ h.frame(9, { update: [{ id: 1, from: [0, 0, 0], pos: [8, 0, 0] }] });
29
+ h.at(5.5);
30
+ expect(h.interp.drawnTick).toBe(5.5);
31
+ // An earlier render tick is raised to what was drawn.
32
+ h.at(3);
33
+ expect(h.interp.drawnTick).toBe(5.5);
34
+ // Frames stopped: past the newest tick received (9), the picture is
35
+ // tick 9's.
36
+ h.at(14);
37
+ expect(h.interp.drawnTick).toBe(9);
38
+ h.interp.clear();
39
+ expect(h.interp.drawnTick).toBe(-1);
40
+ });
41
+
24
42
  test("draws an entity between the samples around the render tick", () => {
25
43
  const h = harness();
26
44
  h.frame(1, { full: true, spawn: [{ id: 1, pos: [0, 0, 0] }] });
@@ -90,6 +90,7 @@ export class EntityInterpolator {
90
90
  /** The newest tick the last `update` drew; a render tick never goes
91
91
  * below it. */
92
92
  private floor = -Infinity;
93
+ private drawn = -1;
93
94
  /** Lives that ended, oldest first, so `record` can drop them when
94
95
  * `update` does not run (a hidden tab keeps receiving frames). */
95
96
  private ended: Array<{ id: number; life: Life }> = [];
@@ -105,12 +106,23 @@ export class EntityInterpolator {
105
106
  return this.lastTick;
106
107
  }
107
108
 
109
+ /**
110
+ * The tick the last `update` drew: its render tick, raised to what an
111
+ * earlier update drew and capped at the newest tick received, or -1 before
112
+ * the first update. This is the tick the player saw: the view tick for lag
113
+ * compensation.
114
+ */
115
+ get drawnTick(): number {
116
+ return this.drawn;
117
+ }
118
+
108
119
  /** Forget everything. The next `update` removes every entity. */
109
120
  clear(): void {
110
121
  this.lives.clear();
111
122
  this.ended = [];
112
123
  this.lastTick = -1;
113
124
  this.floor = -Infinity;
125
+ this.drawn = -1;
114
126
  }
115
127
 
116
128
  /**
@@ -167,6 +179,9 @@ export class EntityInterpolator {
167
179
  renderTick = Math.max(renderTick, this.floor);
168
180
  // What this update draws is at most the newest tick recorded.
169
181
  this.floor = Math.min(renderTick, this.lastTick);
182
+ // Past the newest tick received, entities hold at their last samples:
183
+ // the picture is that tick's.
184
+ this.drawn = this.floor;
170
185
  for (const [id, lives] of this.lives) {
171
186
  // Lives that ended by the render tick are over.
172
187
  while (lives.length > 0 && lives[0].despawnTick !== null && lives[0].despawnTick <= renderTick) {