openrtc-netcode 3.0.1 → 3.0.2

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,74 @@
1
+ import type { NetcodeObjectState } from './types.js';
2
+ /** Lossless moving fields only; immutable identity, scale and metadata stay in the reliable view. */
3
+ export declare function jsonMotionRecord(object: NetcodeObjectState, index: number): {
4
+ index: number;
5
+ revision: number;
6
+ tick: number;
7
+ updatedAt: number;
8
+ transform: {
9
+ position: {
10
+ x: number;
11
+ y: number;
12
+ z: number;
13
+ };
14
+ rotation: {
15
+ x: number;
16
+ y: number;
17
+ z: number;
18
+ w: number;
19
+ } | undefined;
20
+ };
21
+ physics: {
22
+ linearVelocity: {
23
+ x: number;
24
+ y: number;
25
+ z: number;
26
+ } | undefined;
27
+ angularVelocity: {
28
+ x: number;
29
+ y: number;
30
+ z: number;
31
+ } | undefined;
32
+ sleeping: boolean | undefined;
33
+ } | undefined;
34
+ teleport: boolean | undefined;
35
+ };
36
+ export declare function encodeJsonMotion(objects: readonly NetcodeObjectState[], ids: readonly string[]): {
37
+ index: number;
38
+ revision: number;
39
+ tick: number;
40
+ updatedAt: number;
41
+ transform: {
42
+ position: {
43
+ x: number;
44
+ y: number;
45
+ z: number;
46
+ };
47
+ rotation: {
48
+ x: number;
49
+ y: number;
50
+ z: number;
51
+ w: number;
52
+ } | undefined;
53
+ };
54
+ physics: {
55
+ linearVelocity: {
56
+ x: number;
57
+ y: number;
58
+ z: number;
59
+ } | undefined;
60
+ angularVelocity: {
61
+ x: number;
62
+ y: number;
63
+ z: number;
64
+ } | undefined;
65
+ sleeping: boolean | undefined;
66
+ } | undefined;
67
+ teleport: boolean | undefined;
68
+ }[];
69
+ export declare function decodeJsonMotion(records: unknown[], definitions: readonly NetcodeObjectState[]): NetcodeObjectState[];
70
+ /** V2 full-motion records; static definitions are bound to the enclosing view revision. */
71
+ export declare function encodeMotion(objects: readonly NetcodeObjectState[], ids: readonly string[]): Uint8Array;
72
+ export declare function decodeMotion(bytes: Uint8Array, definitions: readonly NetcodeObjectState[]): NetcodeObjectState[];
73
+ export declare function motionToBase64(bytes: Uint8Array): string;
74
+ export declare function motionFromBase64(value: string): Uint8Array;
package/dist/codec.js ADDED
@@ -0,0 +1,167 @@
1
+ import { isNetcodeObjectState } from './objects.js';
2
+ import { isCounter } from './validation.js';
3
+ /** Lossless moving fields only; immutable identity, scale and metadata stay in the reliable view. */
4
+ export function jsonMotionRecord(object, index) {
5
+ const vector = (value) => value && ({ x: value.x, y: value.y, z: value.z });
6
+ const rotation = object.transform.rotation;
7
+ return { index, revision: object.revision, tick: object.tick, updatedAt: object.updatedAt,
8
+ transform: { position: vector(object.transform.position), rotation: rotation && { x: rotation.x, y: rotation.y, z: rotation.z, w: rotation.w } },
9
+ physics: object.physics && { linearVelocity: vector(object.physics.linearVelocity),
10
+ angularVelocity: vector(object.physics.angularVelocity), sleeping: object.physics.sleeping },
11
+ teleport: object.teleport };
12
+ }
13
+ export function encodeJsonMotion(objects, ids) {
14
+ if (objects.length > 1024 || ids.length > 10000)
15
+ throw new Error('Motion record capacity exceeded.');
16
+ const indexes = new Map(ids.map((id, index) => [id, index]));
17
+ return objects.map((object) => {
18
+ const index = indexes.get(object.id);
19
+ if (index === undefined)
20
+ throw new Error('Motion baseline rejected.');
21
+ return jsonMotionRecord(object, index);
22
+ });
23
+ }
24
+ export function decodeJsonMotion(records, definitions) {
25
+ if (records.length > 1024)
26
+ throw new Error('Motion record capacity exceeded.');
27
+ const seen = new Set();
28
+ return records.map((value) => {
29
+ if (!value || typeof value !== 'object' || Array.isArray(value))
30
+ throw new Error('Motion record rejected.');
31
+ const record = value, index = record.index;
32
+ if (typeof index !== 'number' || !Number.isSafeInteger(index) || index < 0 || !definitions[index] || seen.has(index))
33
+ throw new Error('Motion baseline rejected.');
34
+ seen.add(index);
35
+ const definition = definitions[index];
36
+ const transform = record.transform;
37
+ const object = { ...definition, revision: record.revision, tick: record.tick, updatedAt: record.updatedAt,
38
+ transform: { ...definition.transform, position: transform?.position, rotation: transform?.rotation },
39
+ physics: record.physics, teleport: record.teleport };
40
+ if (!isNetcodeObjectState(object))
41
+ throw new Error('Motion state rejected.');
42
+ return object;
43
+ });
44
+ }
45
+ const HEADER = 5, RECORD = 59;
46
+ const vectorAxes = ['x', 'y', 'z'];
47
+ /** V2 full-motion records; static definitions are bound to the enclosing view revision. */
48
+ export function encodeMotion(objects, ids) {
49
+ if (objects.length > 1024 || ids.length > 10000)
50
+ throw new Error('Motion record capacity exceeded.');
51
+ const indexes = new Map(ids.map((id, index) => [id, index]));
52
+ const bytes = new Uint8Array(HEADER + objects.length * RECORD), view = new DataView(bytes.buffer);
53
+ view.setUint16(0, 0x4e43);
54
+ view.setUint8(2, 2);
55
+ view.setUint16(3, objects.length);
56
+ let offset = HEADER;
57
+ for (const object of objects) {
58
+ const index = indexes.get(object.id);
59
+ if (index === undefined || !isCounter(object.revision) || object.revision === 0 || !isCounter(object.tick))
60
+ throw new Error('Motion identity rejected.');
61
+ view.setUint16(offset, index);
62
+ offset += 2;
63
+ view.setFloat64(offset, object.revision);
64
+ offset += 8;
65
+ view.setFloat64(offset, object.tick);
66
+ offset += 8;
67
+ if (!Number.isFinite(object.updatedAt))
68
+ throw new Error('Motion timestamp rejected.');
69
+ view.setFloat64(offset, object.updatedAt);
70
+ offset += 8;
71
+ for (const axis of vectorAxes) {
72
+ const value = Math.round(object.transform.position[axis] * 1000);
73
+ if (!Number.isFinite(value) || value < -2147483648 || value > 2147483647)
74
+ throw new Error('Position exceeds quantized range.');
75
+ view.setInt32(offset, value);
76
+ offset += 4;
77
+ }
78
+ const quaternion = object.transform.rotation ?? { x: 0, y: 0, z: 0, w: 1 };
79
+ const norm = Math.hypot(quaternion.x, quaternion.y, quaternion.z, quaternion.w);
80
+ if (!Number.isFinite(norm) || norm < 1e-8)
81
+ throw new Error('Rotation rejected.');
82
+ for (const axis of ['x', 'y', 'z', 'w']) {
83
+ view.setInt16(offset, Math.round(quaternion[axis] / norm * 32767));
84
+ offset += 2;
85
+ }
86
+ for (const velocity of [object.physics?.linearVelocity, object.physics?.angularVelocity]) {
87
+ for (const axis of vectorAxes) {
88
+ const value = Math.round((velocity?.[axis] ?? 0) * 100);
89
+ if (!Number.isFinite(value) || value < -32768 || value > 32767)
90
+ throw new Error('Velocity exceeds quantized range.');
91
+ view.setInt16(offset, value);
92
+ offset += 2;
93
+ }
94
+ }
95
+ view.setUint8(offset++, Number(!!object.physics?.sleeping) | (Number(!!object.teleport) << 1));
96
+ }
97
+ return bytes;
98
+ }
99
+ export function decodeMotion(bytes, definitions) {
100
+ if (bytes.byteLength < HEADER)
101
+ throw new Error('Truncated motion packet.');
102
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
103
+ const count = view.getUint16(3);
104
+ if (view.getUint16(0) !== 0x4e43 || view.getUint8(2) !== 2 || count > 1024 || bytes.byteLength !== HEADER + count * RECORD)
105
+ throw new Error('Motion wire format rejected.');
106
+ const objects = [], seen = new Set();
107
+ let offset = HEADER;
108
+ for (let i = 0; i < count; i++) {
109
+ const index = view.getUint16(offset);
110
+ offset += 2;
111
+ const definition = definitions[index];
112
+ if (!definition || seen.has(index))
113
+ throw new Error('Motion baseline rejected.');
114
+ seen.add(index);
115
+ const revision = view.getFloat64(offset);
116
+ offset += 8;
117
+ const tick = view.getFloat64(offset);
118
+ offset += 8;
119
+ const updatedAt = view.getFloat64(offset);
120
+ offset += 8;
121
+ if (!isCounter(revision) || revision === 0 || !isCounter(tick) || !Number.isFinite(updatedAt))
122
+ throw new Error('Motion version rejected.');
123
+ const position = { x: 0, y: 0, z: 0 };
124
+ for (const axis of vectorAxes) {
125
+ position[axis] = view.getInt32(offset) / 1000;
126
+ offset += 4;
127
+ }
128
+ const rotation = { x: 0, y: 0, z: 0, w: 1 };
129
+ for (const axis of ['x', 'y', 'z', 'w']) {
130
+ rotation[axis] = view.getInt16(offset) / 32767;
131
+ offset += 2;
132
+ }
133
+ const norm = Math.hypot(rotation.x, rotation.y, rotation.z, rotation.w);
134
+ if (norm < 1e-8)
135
+ throw new Error('Motion quaternion rejected.');
136
+ for (const axis of ['x', 'y', 'z', 'w'])
137
+ rotation[axis] /= norm;
138
+ const velocities = [];
139
+ for (let j = 0; j < 2; j++) {
140
+ const velocity = { x: 0, y: 0, z: 0 };
141
+ for (const axis of vectorAxes) {
142
+ velocity[axis] = view.getInt16(offset) / 100;
143
+ offset += 2;
144
+ }
145
+ velocities.push(velocity);
146
+ }
147
+ const flags = view.getUint8(offset++);
148
+ if (flags > 3)
149
+ throw new Error('Motion flags rejected.');
150
+ objects.push({ ...definition, revision, tick, updatedAt, teleport: !!(flags & 2),
151
+ transform: { ...definition.transform, position, rotation },
152
+ physics: { linearVelocity: velocities[0], angularVelocity: velocities[1], sleeping: !!(flags & 1) },
153
+ });
154
+ }
155
+ return objects;
156
+ }
157
+ export function motionToBase64(bytes) {
158
+ let value = '';
159
+ for (let i = 0; i < bytes.length; i += 4096)
160
+ value += String.fromCharCode(...bytes.subarray(i, i + 4096));
161
+ return btoa(value);
162
+ }
163
+ export function motionFromBase64(value) {
164
+ if (value.length > 128 * 1024 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value))
165
+ throw new Error('Motion encoding rejected.');
166
+ return Uint8Array.from(atob(value), (character) => character.charCodeAt(0));
167
+ }
@@ -0,0 +1,9 @@
1
+ /** Incremental bounded u32be decoder; each payload byte is copied once. */
2
+ export declare class FrameDecoder {
3
+ private readonly header;
4
+ private headerBytes;
5
+ private frame;
6
+ private frameBytes;
7
+ feed(chunk: Uint8Array, receive: (frame: Uint8Array) => void): void;
8
+ get incomplete(): boolean;
9
+ }
@@ -0,0 +1,39 @@
1
+ import { MAX_FRAME_BYTES } from './validation.js';
2
+ /** Incremental bounded u32be decoder; each payload byte is copied once. */
3
+ export class FrameDecoder {
4
+ constructor() {
5
+ this.header = new Uint8Array(4);
6
+ this.headerBytes = 0;
7
+ this.frame = null;
8
+ this.frameBytes = 0;
9
+ }
10
+ feed(chunk, receive) {
11
+ let offset = 0;
12
+ while (offset < chunk.byteLength) {
13
+ if (!this.frame) {
14
+ const count = Math.min(4 - this.headerBytes, chunk.byteLength - offset);
15
+ this.header.set(chunk.subarray(offset, offset + count), this.headerBytes);
16
+ this.headerBytes += count;
17
+ offset += count;
18
+ if (this.headerBytes !== 4)
19
+ continue;
20
+ const length = new DataView(this.header.buffer).getUint32(0);
21
+ if (length === 0 || length > MAX_FRAME_BYTES)
22
+ throw new Error('Netcode frame size rejected.');
23
+ this.frame = new Uint8Array(length);
24
+ this.frameBytes = 0;
25
+ this.headerBytes = 0;
26
+ }
27
+ const count = Math.min(this.frame.byteLength - this.frameBytes, chunk.byteLength - offset);
28
+ this.frame.set(chunk.subarray(offset, offset + count), this.frameBytes);
29
+ this.frameBytes += count;
30
+ offset += count;
31
+ if (this.frameBytes === this.frame.byteLength) {
32
+ const frame = this.frame;
33
+ this.frame = null;
34
+ receive(frame);
35
+ }
36
+ }
37
+ }
38
+ get incomplete() { return this.headerBytes > 0 || this.frame !== null; }
39
+ }
package/dist/index.d.ts CHANGED
@@ -4,3 +4,10 @@ export { ObjectStore } from './objects.js';
4
4
  export { NETCODE_PROTOCOL, PROTOCOL_VERSION, decodeEnvelope, encodeEnvelope, isEnvelopeKind, } from './protocol.js';
5
5
  export type { HelloBody, MetadataBody, NetcodeEnvelope, NetcodeEnvelopeKind, ObjectRemoveBody, ObjectSnapshotBody, ObjectUpsertBody, StatePatchBody, StateSnapshotBody, UserMessageBody, } from './protocol.js';
6
6
  export type { CreateLobbyOptions, JoinLobbyOptions, NetVar, NetVarOptions, NetcodeClient, NetcodeEventHandler, NetcodeEventName, NetcodeEvents, NetcodeLobby, NetcodeMessage, MessageHandler, NetcodeObjectChange, NetcodeObjectState, NetcodeObjectStore, NetcodeObjectUpsert, NetcodeOptions, NetcodePeer, NetcodePeerStats, NetcodePhysicsState, NetcodeQuaternion, NetcodeReliability, NetcodeRuntime, NetcodeState, NetcodeTransform, NetcodeVector3, NetcodeVoice, NetcodeVoiceEvents, NetcodeVoiceOptions, NetcodeVoicePeer, SendOptions, } from './types.js';
7
+ export { SimulationClock, SnapshotBuffer, InputPrediction, LagHistory } from './simulation.js';
8
+ export type { TransformSnapshot, SnapshotBufferOptions } from './simulation.js';
9
+ export { ReplicationScheduler } from './replication.js';
10
+ export type { ReplicationOptions, ReplicationStats } from './replication.js';
11
+ export type { ActionHandler, ActionContext } from './actions.js';
12
+ export { encodeMotion, decodeMotion } from './codec.js';
13
+ export type { MotionThresholds } from './motion.js';
package/dist/index.js CHANGED
@@ -2,3 +2,6 @@ export { createNetcode, } from './client.js';
2
2
  export { runtimeFromRoom } from './roomRuntime.js';
3
3
  export { ObjectStore } from './objects.js';
4
4
  export { NETCODE_PROTOCOL, PROTOCOL_VERSION, decodeEnvelope, encodeEnvelope, isEnvelopeKind, } from './protocol.js';
5
+ export { SimulationClock, SnapshotBuffer, InputPrediction, LagHistory } from './simulation.js';
6
+ export { ReplicationScheduler } from './replication.js';
7
+ export { encodeMotion, decodeMotion } from './codec.js';
@@ -1,9 +1,12 @@
1
- /** Coalesces only queued values; the in-flight state is allowed to finish. */
1
+ /** One in-flight write and one replaceable pending value, with bounded promises. */
2
2
  export declare class LatestStateQueue<T> {
3
3
  private readonly transmit;
4
4
  private pending;
5
5
  private running;
6
+ private current;
7
+ private closed;
6
8
  constructor(transmit: (value: T) => Promise<void>);
7
9
  push(value: T): Promise<void>;
10
+ close(error?: Error): void;
8
11
  private pump;
9
12
  }
@@ -1,44 +1,48 @@
1
- /** Coalesces only queued values; the in-flight state is allowed to finish. */
1
+ /** One in-flight write and one replaceable pending value, with bounded promises. */
2
2
  export class LatestStateQueue {
3
3
  constructor(transmit) {
4
4
  this.transmit = transmit;
5
5
  this.pending = null;
6
6
  this.running = false;
7
+ this.current = null;
8
+ this.closed = null;
7
9
  }
8
10
  push(value) {
11
+ if (this.closed)
12
+ return Promise.reject(this.closed);
9
13
  return new Promise((resolve, reject) => {
10
- if (this.pending) {
11
- this.pending.value = value;
12
- this.pending.waiters.push({ resolve, reject });
13
- }
14
- else {
15
- this.pending = { value, waiters: [{ resolve, reject }] };
16
- }
14
+ this.pending?.resolve();
15
+ this.pending = { value, resolve, reject };
17
16
  this.pump();
18
17
  });
19
18
  }
19
+ close(error = new Error('Netcode match retired.')) {
20
+ this.closed = error;
21
+ this.current?.reject(error);
22
+ this.pending?.reject(error);
23
+ this.pending = null;
24
+ }
20
25
  pump() {
21
- if (this.running)
26
+ if (this.running || this.closed)
22
27
  return;
23
28
  this.running = true;
24
29
  void (async () => {
25
- while (this.pending) {
30
+ while (this.pending && !this.closed) {
26
31
  const current = this.pending;
27
32
  this.pending = null;
33
+ this.current = current;
28
34
  try {
29
35
  await this.transmit(current.value);
30
- for (const waiter of current.waiters)
31
- waiter.resolve();
36
+ current.resolve();
32
37
  }
33
38
  catch (error) {
34
- for (const waiter of current.waiters)
35
- waiter.reject(error);
39
+ current.reject(error);
40
+ }
41
+ finally {
42
+ this.current = null;
36
43
  }
37
44
  }
38
- })().finally(() => {
39
- this.running = false;
40
- if (this.pending)
41
- this.pump();
42
- });
45
+ })().finally(() => { this.running = false; if (this.pending && !this.closed)
46
+ this.pump(); });
43
47
  }
44
48
  }
@@ -0,0 +1,9 @@
1
+ import type { NetcodeObjectState } from './types.js';
2
+ export interface MotionThresholds {
3
+ position?: number;
4
+ rotationRadians?: number;
5
+ linearVelocity?: number;
6
+ angularVelocity?: number;
7
+ maxSilenceMs?: number;
8
+ }
9
+ export declare function motionChanged(a: NetcodeObjectState, b: NetcodeObjectState, ageMs: number, options: MotionThresholds): boolean;
package/dist/motion.js ADDED
@@ -0,0 +1,12 @@
1
+ export function motionChanged(a, b, ageMs, options) {
2
+ if (b.teleport || ageMs >= (options.maxSilenceMs ?? 1000) || a.physics?.sleeping !== b.physics?.sleeping)
3
+ return true;
4
+ const distance = (left, right) => Math.hypot((left?.x ?? 0) - (right?.x ?? 0), (left?.y ?? 0) - (right?.y ?? 0), (left?.z ?? 0) - (right?.z ?? 0));
5
+ if (distance(a.transform.position, b.transform.position) > (options.position ?? 0.001)
6
+ || distance(a.physics?.linearVelocity, b.physics?.linearVelocity) > (options.linearVelocity ?? 0.01)
7
+ || distance(a.physics?.angularVelocity, b.physics?.angularVelocity) > (options.angularVelocity ?? 0.01))
8
+ return true;
9
+ const qa = a.transform.rotation ?? { x: 0, y: 0, z: 0, w: 1 }, qb = b.transform.rotation ?? { x: 0, y: 0, z: 0, w: 1 };
10
+ const dot = Math.abs(qa.x * qb.x + qa.y * qb.y + qa.z * qb.z + qa.w * qb.w) / (Math.hypot(qa.x, qa.y, qa.z, qa.w) * Math.hypot(qb.x, qb.y, qb.z, qb.w));
11
+ return 2 * Math.acos(Math.min(1, dot)) > (options.rotationRadians ?? 0.001);
12
+ }
package/dist/objects.d.ts CHANGED
@@ -1,28 +1,57 @@
1
1
  import type { NetcodeObjectChange, NetcodeObjectStore, NetcodeObjectState, NetcodeObjectUpsert } from './types.js';
2
+ import type { ObjectRemoveBody } from './protocol.js';
2
3
  type ObjectListener = (change: NetcodeObjectChange) => void;
3
4
  export declare class ObjectStore implements NetcodeObjectStore {
4
5
  private readonly localPeerId;
5
6
  private readonly publishUpsert;
6
7
  private readonly publishRemove;
8
+ private readonly capacity;
9
+ private readonly incarnation;
10
+ private readonly tick;
11
+ private readonly canWrite;
7
12
  private readonly objectsById;
13
+ private readonly removedById;
8
14
  private readonly listeners;
9
15
  private nextObjectId;
10
- constructor(localPeerId: () => string | null, publishUpsert: (object: NetcodeObjectState) => Promise<void>, publishRemove: (id: string) => Promise<void>);
16
+ private readonly instanceId;
17
+ private readonly structureListeners;
18
+ private readonly idSnapshots;
19
+ private readonly entryBytes;
20
+ private bytes;
21
+ private snapshotBatch;
22
+ private structurePending;
23
+ constructor(localPeerId: () => string | null, publishUpsert: (object: NetcodeObjectState) => Promise<void>, publishRemove: (removal: ObjectRemoveBody) => Promise<void>, capacity?: number, incarnation?: (kind?: string) => string, tick?: () => number, canWrite?: (kind?: string) => boolean);
11
24
  list(): NetcodeObjectState[];
12
25
  get(id: string): NetcodeObjectState | undefined;
26
+ /** Immutable, allocation-free read for render loops. */
27
+ read(id: string): Readonly<NetcodeObjectState> | undefined;
28
+ ids(kind?: string): readonly string[];
29
+ onStructureChange(listener: () => void): () => void;
30
+ removeOwner(ownerId: string, kinds?: ReadonlySet<string>): void;
31
+ pruneOwner(ownerId: string, visible: ReadonlySet<string>): void;
13
32
  snapshot(): Record<string, NetcodeObjectState>;
14
33
  spawn(input: NetcodeObjectUpsert): Promise<NetcodeObjectState>;
15
34
  upsert(input: NetcodeObjectUpsert): Promise<NetcodeObjectState>;
16
- update(id: string, patch: Partial<Omit<NetcodeObjectState, 'id'>>): Promise<NetcodeObjectState>;
35
+ update(id: string, patch: Partial<Omit<NetcodeObjectState, 'id' | 'revision'>>): Promise<NetcodeObjectState>;
17
36
  remove(id: string): Promise<void>;
18
37
  onChange(listener: ObjectListener): () => void;
19
38
  clear(): void;
20
- applySnapshot(objects: Record<string, NetcodeObjectState>, from: string): void;
21
- applyUpsert(object: NetcodeObjectState, from: string): void;
22
- applyRemove(id: string, from: string): void;
39
+ removalSnapshot(): Record<string, ObjectRemoveBody>;
40
+ applySnapshot(objects: Record<string, NetcodeObjectState>, from: string, removed?: Record<string, ObjectRemoveBody>): {
41
+ objects: Record<string, NetcodeObjectState>;
42
+ removed: Record<string, ObjectRemoveBody>;
43
+ };
44
+ applyUpsert(object: NetcodeObjectState, from: string): boolean;
45
+ applyRemove(id: string, sender: string, removal: ObjectRemoveBody, from?: string): boolean;
23
46
  private applyObject;
47
+ private assertOwner;
24
48
  private createObjectId;
49
+ private structureChanged;
25
50
  private emit;
51
+ private fitsBytes;
52
+ private setBytes;
53
+ private releaseBytes;
26
54
  }
27
55
  export declare function isNetcodeObjectState(value: unknown): value is NetcodeObjectState;
56
+ export declare function isObjectRemoval(value: unknown): value is ObjectRemoveBody;
28
57
  export {};