modd-network 1.0.1 → 1.0.3

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,192 @@
1
+ "use strict";
2
+ /**
3
+ * Input Codec - Compresses readable inputs to binary
4
+ *
5
+ * Game sends: { type: 'keydown', key: 'w' }
6
+ * Wire sends: [0x01, 0x80] (2 bytes)
7
+ *
8
+ * Game sends: { type: 'camera', yaw: 1.5, pitch: 0.3 }
9
+ * Wire sends: [0x02, yaw_lo, yaw_hi, pitch_lo, pitch_hi] (5 bytes)
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.compress = compress;
13
+ exports.compressBatch = compressBatch;
14
+ exports.decompress = decompress;
15
+ exports.decompressBatch = decompressBatch;
16
+ exports.canCompress = canCompress;
17
+ // Message types
18
+ const MSG = {
19
+ KEY: 0x01,
20
+ CAMERA: 0x02,
21
+ SHOOT: 0x03,
22
+ JOIN: 0x04,
23
+ LEAVE: 0x05,
24
+ };
25
+ // Key indices
26
+ const KEY_MAP = {
27
+ 'w': 0, 'a': 1, 's': 2, 'd': 3,
28
+ 'space': 4, ' ': 4,
29
+ 'shift': 5, 'ctrl': 6, 'control': 6,
30
+ 'e': 7, 'q': 8, 'r': 9, 'f': 10,
31
+ 'mouse0': 11, 'mouse1': 12, 'mouse2': 13,
32
+ };
33
+ const KEY_NAMES = [
34
+ 'w', 'a', 's', 'd', 'space', 'shift', 'ctrl',
35
+ 'e', 'q', 'r', 'f', 'mouse0', 'mouse1', 'mouse2'
36
+ ];
37
+ const KEY_DOWN_BIT = 0x80;
38
+ /**
39
+ * Compress a readable input to binary
40
+ */
41
+ function compress(input) {
42
+ if (!input || !input.type)
43
+ return null;
44
+ switch (input.type) {
45
+ case 'keydown':
46
+ case 'keyup': {
47
+ const down = input.type === 'keydown';
48
+ const keyIdx = KEY_MAP[input.key?.toLowerCase()] ?? 0;
49
+ return new Uint8Array([MSG.KEY, keyIdx | (down ? KEY_DOWN_BIT : 0)]);
50
+ }
51
+ case 'camera': {
52
+ const buf = new Uint8Array(5);
53
+ buf[0] = MSG.CAMERA;
54
+ // Normalize yaw to [-π, π] to fit in Int16 range
55
+ let yaw = (input.yaw ?? 0) % (Math.PI * 2);
56
+ if (yaw > Math.PI)
57
+ yaw -= Math.PI * 2;
58
+ if (yaw < -Math.PI)
59
+ yaw += Math.PI * 2;
60
+ // Pitch is naturally bounded to [-π/2, π/2]
61
+ const pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, input.pitch ?? 0));
62
+ const yawInt = Math.round(yaw * 10000);
63
+ const pitchInt = Math.round(pitch * 10000);
64
+ buf[1] = yawInt & 0xFF;
65
+ buf[2] = (yawInt >> 8) & 0xFF;
66
+ buf[3] = pitchInt & 0xFF;
67
+ buf[4] = (pitchInt >> 8) & 0xFF;
68
+ return buf;
69
+ }
70
+ case 'shoot':
71
+ return new Uint8Array([MSG.SHOOT]);
72
+ case 'join':
73
+ return new Uint8Array([MSG.JOIN]);
74
+ case 'leave':
75
+ return new Uint8Array([MSG.LEAVE]);
76
+ default:
77
+ // Unknown type - can't compress
78
+ return null;
79
+ }
80
+ }
81
+ /**
82
+ * Compress multiple inputs into single buffer
83
+ */
84
+ function compressBatch(inputs) {
85
+ if (inputs.length === 0)
86
+ return null;
87
+ if (inputs.length === 1)
88
+ return compress(inputs[0]);
89
+ // Calculate total size
90
+ const buffers = [];
91
+ for (const input of inputs) {
92
+ const buf = compress(input);
93
+ if (!buf)
94
+ return null; // Can't compress one = can't compress batch
95
+ buffers.push(buf);
96
+ }
97
+ // Concatenate with length prefix
98
+ const totalSize = buffers.reduce((sum, b) => sum + b.length, 0);
99
+ const result = new Uint8Array(1 + totalSize);
100
+ result[0] = 0x00; // Batch marker
101
+ let offset = 1;
102
+ for (const buf of buffers) {
103
+ result.set(buf, offset);
104
+ offset += buf.length;
105
+ }
106
+ return result;
107
+ }
108
+ /**
109
+ * Decompress binary back to readable input
110
+ */
111
+ function decompress(buf, offset = 0) {
112
+ if (buf.length <= offset)
113
+ return null;
114
+ const type = buf[offset];
115
+ switch (type) {
116
+ case MSG.KEY: {
117
+ const keyByte = buf[offset + 1];
118
+ const down = (keyByte & KEY_DOWN_BIT) !== 0;
119
+ const keyIdx = keyByte & 0x7F;
120
+ return {
121
+ input: {
122
+ type: down ? 'keydown' : 'keyup',
123
+ key: KEY_NAMES[keyIdx] ?? 'unknown'
124
+ },
125
+ bytesRead: 2
126
+ };
127
+ }
128
+ case MSG.CAMERA: {
129
+ const yawLo = buf[offset + 1];
130
+ const yawHi = buf[offset + 2];
131
+ const pitchLo = buf[offset + 3];
132
+ const pitchHi = buf[offset + 4];
133
+ let yawInt = yawLo | (yawHi << 8);
134
+ let pitchInt = pitchLo | (pitchHi << 8);
135
+ // Sign extend
136
+ if (yawInt > 32767)
137
+ yawInt -= 65536;
138
+ if (pitchInt > 32767)
139
+ pitchInt -= 65536;
140
+ return {
141
+ input: {
142
+ type: 'camera',
143
+ yaw: yawInt / 10000,
144
+ pitch: pitchInt / 10000
145
+ },
146
+ bytesRead: 5
147
+ };
148
+ }
149
+ case MSG.SHOOT:
150
+ return { input: { type: 'shoot' }, bytesRead: 1 };
151
+ case MSG.JOIN:
152
+ return { input: { type: 'join' }, bytesRead: 1 };
153
+ case MSG.LEAVE:
154
+ return { input: { type: 'leave' }, bytesRead: 1 };
155
+ default:
156
+ return null;
157
+ }
158
+ }
159
+ /**
160
+ * Decompress a batch of inputs
161
+ */
162
+ function decompressBatch(buf) {
163
+ if (buf.length === 0)
164
+ return [];
165
+ // Check for batch marker
166
+ if (buf[0] === 0x00) {
167
+ const results = [];
168
+ let offset = 1;
169
+ while (offset < buf.length) {
170
+ const result = decompress(buf, offset);
171
+ if (!result)
172
+ break;
173
+ results.push(result.input);
174
+ offset += result.bytesRead;
175
+ }
176
+ return results;
177
+ }
178
+ // Single message
179
+ const result = decompress(buf, 0);
180
+ return result ? [result.input] : [];
181
+ }
182
+ /**
183
+ * Check if an input can be compressed
184
+ * Note: join/leave are excluded because they contain unique player ID data
185
+ */
186
+ function canCompress(input) {
187
+ if (!input?.type)
188
+ return false;
189
+ // shoot has extra data (position, direction, snapshots) - must be JSON
190
+ // join/leave contain player ID that must be preserved - send as JSON
191
+ return ['keydown', 'keyup', 'camera'].includes(input.type);
192
+ }
@@ -1,12 +1,20 @@
1
1
  /**
2
2
  * MODD Engine SDK
3
- * Game synchronization utilities for multiplayer games
4
- * - Proximity-based authority
5
- * - Delta compression
6
- * - Rest detection
7
- * - Soft sync / interpolation
3
+ * Unified game networking with state synchronization
4
+ *
5
+ * Usage:
6
+ * const game = await moddEngine.connect('my-app-id', 'my-room', {
7
+ * stateSync: { lerpSpeed: 0.3 },
8
+ * onConnect: (snapshot, inputs, frame) => { ... }
9
+ * });
10
+ *
11
+ * game.setLocalPlayer(playerId);
12
+ * game.addPlayer(playerId, { x, y, z });
13
+ * game.registerEntity('box1', adapter);
14
+ * game.tick();
8
15
  */
9
- import type { Connection } from './modd-network';
16
+ import { type Connection, type ConnectOptions } from './modd-network';
17
+ export { type Connection, type ConnectOptions, type Input } from './modd-network';
10
18
  export interface EntityState {
11
19
  x: number;
12
20
  y: number;
@@ -27,69 +35,82 @@ export interface PhysicsAdapter {
27
35
  getState(): EntityState;
28
36
  setState(state: EntityState): void;
29
37
  }
30
- export interface EngineOptions {
31
- softSyncLerp?: number;
38
+ export interface StateSyncConfig {
39
+ lerpSpeed?: number;
32
40
  positionThreshold?: number;
33
41
  velocityThreshold?: number;
34
42
  rotationThreshold?: number;
35
43
  restVelocityThreshold?: number;
36
44
  restFramesRequired?: number;
37
- touchBonusScore?: number;
38
- touchBonusDecay?: number;
45
+ authorityRadius?: number;
46
+ playerSendRate?: number;
47
+ entitySendRate?: number;
48
+ staleThreshold?: number;
39
49
  }
40
- export interface Entity {
50
+ export interface GameConnectOptions extends ConnectOptions {
51
+ stateSync?: StateSyncConfig;
52
+ }
53
+ interface GameInternal extends Game {
54
+ setConnection(conn: Connection): void;
55
+ }
56
+ export interface TrackedEntity {
41
57
  id: string;
42
58
  adapter: PhysicsAdapter;
43
- state: EntityState;
44
- lastSentState?: EntityState;
59
+ localState: EntityState;
60
+ targetState: EntityState;
61
+ lastSentState: EntityState;
45
62
  restFrames: number;
46
- lastTouchedBy?: string;
47
- lastTouchFrame?: number;
63
+ isAuthority: boolean;
64
+ lastUpdate: number;
48
65
  }
49
- export interface Player {
66
+ export interface TrackedPlayer {
50
67
  id: string;
51
- x: number;
52
- y: number;
53
- z: number;
54
- dead?: boolean;
68
+ state: EntityState;
69
+ targetState: EntityState;
70
+ lastUpdate: number;
55
71
  }
56
- export declare function createEngine(options?: EngineOptions): {
57
- readonly config: {
58
- softSyncLerp: number;
59
- positionThreshold: number;
60
- velocityThreshold: number;
61
- rotationThreshold: number;
62
- restVelocityThreshold: number;
63
- restFramesRequired: number;
64
- touchBonusScore: number;
65
- touchBonusDecay: number;
66
- };
67
- setConnection(conn: Connection): void;
68
- setLocalPlayer(playerId: string): void;
69
- setFrame(frame: number): void;
72
+ export interface Game {
73
+ readonly connected: boolean;
74
+ readonly room: string | null;
75
+ readonly node: string | null;
76
+ readonly bandwidthIn: number;
77
+ readonly bandwidthOut: number;
78
+ readonly frame: number;
79
+ readonly localPlayerId: string | null;
80
+ readonly config: StateSyncConfig;
81
+ disconnect(): void;
82
+ send(data: any): void;
83
+ setLocalPlayer(id: string): void;
84
+ addPlayer(id: string, initialState: EntityState): void;
85
+ removePlayer(id: string): void;
86
+ getPlayer(id: string): TrackedPlayer | undefined;
87
+ getPlayers(): TrackedPlayer[];
88
+ updateLocalPlayerState(state: EntityState): void;
89
+ isPlayerStale(id: string): boolean;
90
+ getStalePlayers(): TrackedPlayer[];
70
91
  registerEntity(id: string, adapter: PhysicsAdapter): void;
71
92
  unregisterEntity(id: string): void;
72
- getEntity(id: string): Entity | undefined;
73
- updatePlayer(id: string, x: number, y: number, z: number, dead?: boolean): void;
74
- removePlayer(id: string): void;
75
- computeAuthority: (entityId: string) => string | null;
76
- isLocalAuthority: (entityId: string) => boolean;
77
- syncFromPhysics(entityId: string): void;
78
- syncAllFromPhysics(): void;
79
- sendUpdates: () => void;
80
- handleEntityState: (id: string, state: EntityState) => void;
81
- handleEntityTouch: (id: string, touchedBy: string, frame: number) => void;
82
- checkPlayerTouches(playerId: string, playerX: number, playerZ: number, touchDistance?: number): void;
83
- checkEntityCollisions(touchDistance?: number): void;
84
- wakeEntity: (entityId: string) => void;
85
- isAtRest: (entityId: string) => boolean;
86
- hasStateChanged: (entityId: string) => boolean;
87
- };
88
- /**
89
- * Rapier 3D physics adapter
90
- */
93
+ getEntity(id: string): TrackedEntity | undefined;
94
+ getEntities(): TrackedEntity[];
95
+ isAuthority(entityId: string): boolean;
96
+ tick(): void;
97
+ processInput(input: any): void;
98
+ }
99
+ declare function createStateSync(options?: StateSyncConfig): GameInternal;
100
+ export declare function connect(appId: string, room: string, options?: GameConnectOptions): Promise<Game>;
91
101
  export declare function rapierAdapter(body: any): PhysicsAdapter;
92
- /**
93
- * Generic 2D physics adapter (for Matter.js, Planck, etc.)
94
- */
95
102
  export declare function generic2DAdapter(getBody: () => any, setBody: (state: any) => void): PhysicsAdapter;
103
+ interface InitOptions {
104
+ threeUrl?: string;
105
+ rapierUrl?: string;
106
+ skipThree?: boolean;
107
+ skipRapier?: boolean;
108
+ }
109
+ export declare function init(options?: InitOptions): Promise<{
110
+ THREE: any;
111
+ RAPIER: any;
112
+ }>;
113
+ export declare function getThree(): any;
114
+ export declare function getRapier(): any;
115
+ export { createStateSync };
116
+ export declare const connectToRoom: typeof connect;