@rapierphysicsplugin/server 1.0.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.
Files changed (78) hide show
  1. package/dist/__tests__/input-buffer.test.d.ts +2 -0
  2. package/dist/__tests__/input-buffer.test.d.ts.map +1 -0
  3. package/dist/__tests__/input-buffer.test.js +53 -0
  4. package/dist/__tests__/input-buffer.test.js.map +1 -0
  5. package/dist/__tests__/integration.test.d.ts +2 -0
  6. package/dist/__tests__/integration.test.d.ts.map +1 -0
  7. package/dist/__tests__/integration.test.js +182 -0
  8. package/dist/__tests__/integration.test.js.map +1 -0
  9. package/dist/__tests__/physics-world-collisions.test.d.ts +2 -0
  10. package/dist/__tests__/physics-world-collisions.test.d.ts.map +1 -0
  11. package/dist/__tests__/physics-world-collisions.test.js +129 -0
  12. package/dist/__tests__/physics-world-collisions.test.js.map +1 -0
  13. package/dist/__tests__/physics-world.test.d.ts +2 -0
  14. package/dist/__tests__/physics-world.test.d.ts.map +1 -0
  15. package/dist/__tests__/physics-world.test.js +164 -0
  16. package/dist/__tests__/physics-world.test.js.map +1 -0
  17. package/dist/__tests__/room.test.d.ts +2 -0
  18. package/dist/__tests__/room.test.d.ts.map +1 -0
  19. package/dist/__tests__/room.test.js +189 -0
  20. package/dist/__tests__/room.test.js.map +1 -0
  21. package/dist/__tests__/state-manager.test.d.ts +2 -0
  22. package/dist/__tests__/state-manager.test.d.ts.map +1 -0
  23. package/dist/__tests__/state-manager.test.js +122 -0
  24. package/dist/__tests__/state-manager.test.js.map +1 -0
  25. package/dist/client-connection.d.ts +18 -0
  26. package/dist/client-connection.d.ts.map +1 -0
  27. package/dist/client-connection.js +41 -0
  28. package/dist/client-connection.js.map +1 -0
  29. package/dist/clock-sync.d.ts +4 -0
  30. package/dist/clock-sync.d.ts.map +1 -0
  31. package/dist/clock-sync.js +10 -0
  32. package/dist/clock-sync.js.map +1 -0
  33. package/dist/index.d.ts +8 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +35 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/input-buffer.d.ts +11 -0
  38. package/dist/input-buffer.d.ts.map +1 -0
  39. package/dist/input-buffer.js +40 -0
  40. package/dist/input-buffer.js.map +1 -0
  41. package/dist/physics-world.d.ts +33 -0
  42. package/dist/physics-world.d.ts.map +1 -0
  43. package/dist/physics-world.js +326 -0
  44. package/dist/physics-world.js.map +1 -0
  45. package/dist/room.d.ts +60 -0
  46. package/dist/room.d.ts.map +1 -0
  47. package/dist/room.js +393 -0
  48. package/dist/room.js.map +1 -0
  49. package/dist/server.d.ts +17 -0
  50. package/dist/server.d.ts.map +1 -0
  51. package/dist/server.js +268 -0
  52. package/dist/server.js.map +1 -0
  53. package/dist/simulation-loop.d.ts +14 -0
  54. package/dist/simulation-loop.d.ts.map +1 -0
  55. package/dist/simulation-loop.js +42 -0
  56. package/dist/simulation-loop.js.map +1 -0
  57. package/dist/state-manager.d.ts +20 -0
  58. package/dist/state-manager.d.ts.map +1 -0
  59. package/dist/state-manager.js +120 -0
  60. package/dist/state-manager.js.map +1 -0
  61. package/package.json +24 -0
  62. package/src/__tests__/input-buffer.test.ts +64 -0
  63. package/src/__tests__/integration.test.ts +227 -0
  64. package/src/__tests__/physics-world-collisions.test.ts +155 -0
  65. package/src/__tests__/physics-world.test.ts +197 -0
  66. package/src/__tests__/room.test.ts +232 -0
  67. package/src/__tests__/state-manager.test.ts +152 -0
  68. package/src/client-connection.ts +52 -0
  69. package/src/clock-sync.ts +16 -0
  70. package/src/index.ts +40 -0
  71. package/src/input-buffer.ts +46 -0
  72. package/src/physics-world.ts +400 -0
  73. package/src/room.ts +487 -0
  74. package/src/server.ts +312 -0
  75. package/src/simulation-loop.ts +48 -0
  76. package/src/state-manager.ts +136 -0
  77. package/tsconfig.json +11 -0
  78. package/tsconfig.tsbuildinfo +1 -0
package/src/room.ts ADDED
@@ -0,0 +1,487 @@
1
+ import type RAPIER from '@dimforge/rapier3d-compat';
2
+ import type {
3
+ BodyDescriptor,
4
+ CollisionEventData,
5
+ ConstraintDescriptor,
6
+ RoomSnapshot,
7
+ Vec3,
8
+ } from '@rapierphysicsplugin/shared';
9
+ import {
10
+ BROADCAST_INTERVAL,
11
+ MessageType,
12
+ encodeMessage,
13
+ encodeRoomState,
14
+ readBodyIdFromMeshBinary,
15
+ readHashFromGeometryDef,
16
+ readBodyIdFromMeshRef,
17
+ readGeometryHashFromMeshRef,
18
+ readMaterialHashFromMeshRef,
19
+ readHashFromTextureDef,
20
+ readHashFromMaterialDef,
21
+ } from '@rapierphysicsplugin/shared';
22
+ import { PhysicsWorld } from './physics-world.js';
23
+ import { SimulationLoop } from './simulation-loop.js';
24
+ import { StateManager } from './state-manager.js';
25
+ import { InputBuffer } from './input-buffer.js';
26
+ import type { ClientConnection } from './client-connection.js';
27
+
28
+ export class Room {
29
+ readonly id: string;
30
+ readonly physicsWorld: PhysicsWorld;
31
+ private simulationLoop: SimulationLoop;
32
+ private stateManager: StateManager;
33
+ private clients: Map<string, ClientConnection> = new Map();
34
+ private inputBuffers: Map<string, InputBuffer> = new Map();
35
+ private initialBodies: BodyDescriptor[] = [];
36
+ private initialConstraints: ConstraintDescriptor[] = [];
37
+ private activeConstraints: Map<string, ConstraintDescriptor> = new Map();
38
+ private activeBodies: Map<string, BodyDescriptor> = new Map();
39
+ private meshBinaryStore: Map<string, Uint8Array> = new Map();
40
+ private geometryStore: Map<string, Uint8Array> = new Map();
41
+ private meshRefStore: Map<string, Uint8Array> = new Map();
42
+ private geometryRefCount: Map<string, Set<string>> = new Map();
43
+ private materialStore: Map<string, Uint8Array> = new Map();
44
+ private textureStore: Map<string, Uint8Array> = new Map();
45
+ private materialRefCount: Map<string, Set<string>> = new Map();
46
+ private currentTick = 0;
47
+ private ticksSinceLastBroadcast = 0;
48
+ private pendingCollisionEvents: CollisionEventData[] = [];
49
+
50
+ constructor(id: string, rapier: typeof RAPIER, gravity?: Vec3) {
51
+ this.id = id;
52
+ this.physicsWorld = new PhysicsWorld(rapier, gravity);
53
+ this.simulationLoop = new SimulationLoop(this);
54
+ this.stateManager = new StateManager();
55
+ }
56
+
57
+ loadInitialState(bodies: BodyDescriptor[], constraints?: ConstraintDescriptor[]): void {
58
+ this.initialBodies = bodies;
59
+ this.initialConstraints = constraints ?? [];
60
+ this.physicsWorld.loadState(bodies);
61
+ for (const b of bodies) {
62
+ this.activeBodies.set(b.id, b);
63
+ }
64
+ for (const c of this.initialConstraints) {
65
+ this.physicsWorld.addConstraint(c);
66
+ this.activeConstraints.set(c.id, c);
67
+ }
68
+ }
69
+
70
+ addClient(conn: ClientConnection): void {
71
+ this.clients.set(conn.id, conn);
72
+ this.inputBuffers.set(conn.id, new InputBuffer());
73
+ conn.roomId = this.id;
74
+
75
+ // Send full state snapshot to the joining client, including body ID mapping and constraints
76
+ const snapshot = this.stateManager.createSnapshot(this.physicsWorld, this.currentTick);
77
+ const constraints = Array.from(this.activeConstraints.values());
78
+ const bodies = Array.from(this.activeBodies.values());
79
+ conn.send(encodeMessage({
80
+ type: MessageType.ROOM_JOINED,
81
+ roomId: this.id,
82
+ snapshot,
83
+ clientId: conn.id,
84
+ simulationRunning: this.simulationLoop.isRunning,
85
+ bodyIdMap: this.stateManager.getIdToIndexRecord(),
86
+ constraints: constraints.length > 0 ? constraints : undefined,
87
+ bodies: bodies.length > 0 ? bodies : undefined,
88
+ }));
89
+
90
+ // Send stored mesh binaries to the late joiner
91
+ for (const [, meshData] of this.meshBinaryStore) {
92
+ conn.send(meshData);
93
+ }
94
+
95
+ // Late-joiner replay order: textures → materials → geometry defs → mesh refs
96
+ for (const [, texData] of this.textureStore) {
97
+ conn.send(texData);
98
+ }
99
+ for (const [, matData] of this.materialStore) {
100
+ conn.send(matData);
101
+ }
102
+ for (const [, geomData] of this.geometryStore) {
103
+ conn.send(geomData);
104
+ }
105
+ for (const [, refData] of this.meshRefStore) {
106
+ conn.send(refData);
107
+ }
108
+ }
109
+
110
+ removeClient(conn: ClientConnection): void {
111
+ this.clients.delete(conn.id);
112
+ this.inputBuffers.delete(conn.id);
113
+ conn.roomId = null;
114
+
115
+ // Stop simulation if no clients remain
116
+ if (this.clients.size === 0) {
117
+ this.simulationLoop.stop();
118
+ }
119
+ }
120
+
121
+ addBody(descriptor: BodyDescriptor): string {
122
+ const id = this.physicsWorld.addBody(descriptor);
123
+ const bodyIndex = this.stateManager.ensureBodyIndex(id);
124
+ // Store descriptor without meshData (mesh geometry arrives via binary channel)
125
+ const stored = { ...descriptor };
126
+ delete (stored as Record<string, unknown>).meshData;
127
+ this.activeBodies.set(id, stored);
128
+
129
+ // Notify all clients (include the numeric index for the new body)
130
+ this.broadcast(encodeMessage({
131
+ type: MessageType.ADD_BODY,
132
+ body: descriptor,
133
+ bodyIndex,
134
+ }));
135
+
136
+ return id;
137
+ }
138
+
139
+ removeBody(bodyId: string): void {
140
+ this.physicsWorld.removeBody(bodyId);
141
+ this.stateManager.removeBody(bodyId);
142
+ this.activeBodies.delete(bodyId);
143
+ this.meshBinaryStore.delete(bodyId);
144
+
145
+ // Clean up geometry + material registry refs (keep defs for reuse)
146
+ const refData = this.meshRefStore.get(bodyId);
147
+ if (refData) {
148
+ const geoHash = readGeometryHashFromMeshRef(refData);
149
+ const geoRefs = this.geometryRefCount.get(geoHash);
150
+ if (geoRefs) {
151
+ geoRefs.delete(bodyId);
152
+ }
153
+
154
+ const matHash = readMaterialHashFromMeshRef(refData);
155
+ const matRefs = this.materialRefCount.get(matHash);
156
+ if (matRefs) {
157
+ matRefs.delete(bodyId);
158
+ }
159
+
160
+ this.meshRefStore.delete(bodyId);
161
+ }
162
+
163
+ // Notify all clients
164
+ this.broadcast(encodeMessage({
165
+ type: MessageType.REMOVE_BODY,
166
+ bodyId,
167
+ }));
168
+ }
169
+
170
+ addConstraint(descriptor: ConstraintDescriptor): string {
171
+ const id = this.physicsWorld.addConstraint(descriptor);
172
+ this.activeConstraints.set(id, descriptor);
173
+
174
+ // Broadcast to all clients
175
+ this.broadcast(encodeMessage({
176
+ type: MessageType.ADD_CONSTRAINT,
177
+ constraint: descriptor,
178
+ }));
179
+
180
+ return id;
181
+ }
182
+
183
+ removeConstraint(constraintId: string): void {
184
+ this.physicsWorld.removeConstraint(constraintId);
185
+ this.activeConstraints.delete(constraintId);
186
+
187
+ // Broadcast to all clients
188
+ this.broadcast(encodeMessage({
189
+ type: MessageType.REMOVE_CONSTRAINT,
190
+ constraintId,
191
+ }));
192
+ }
193
+
194
+ relayMeshBinary(senderId: string, data: Uint8Array): void {
195
+ // Extract bodyId from the header to store for late joiners
196
+ const bodyId = readBodyIdFromMeshBinary(data);
197
+ // Store a copy for late joiners
198
+ this.meshBinaryStore.set(bodyId, new Uint8Array(data));
199
+
200
+ // Broadcast raw bytes to all clients except sender
201
+ for (const [clientId, client] of this.clients) {
202
+ if (clientId !== senderId) {
203
+ client.send(data);
204
+ }
205
+ }
206
+ }
207
+
208
+ relayTextureDef(senderId: string, data: Uint8Array): void {
209
+ const hash = readHashFromTextureDef(data);
210
+
211
+ // Store if new
212
+ if (!this.textureStore.has(hash)) {
213
+ this.textureStore.set(hash, new Uint8Array(data));
214
+ }
215
+
216
+ // Relay to all clients except sender
217
+ for (const [clientId, client] of this.clients) {
218
+ if (clientId !== senderId) {
219
+ client.send(data);
220
+ }
221
+ }
222
+ }
223
+
224
+ relayMaterialDef(senderId: string, data: Uint8Array): void {
225
+ const hash = readHashFromMaterialDef(data);
226
+
227
+ // Store if new
228
+ if (!this.materialStore.has(hash)) {
229
+ this.materialStore.set(hash, new Uint8Array(data));
230
+ }
231
+
232
+ // Relay to all clients except sender
233
+ for (const [clientId, client] of this.clients) {
234
+ if (clientId !== senderId) {
235
+ client.send(data);
236
+ }
237
+ }
238
+ }
239
+
240
+ relayGeometryDef(senderId: string, data: Uint8Array): void {
241
+ const hash = readHashFromGeometryDef(data);
242
+
243
+ // Store if new (skip if already stored — another client already sent this geometry)
244
+ if (!this.geometryStore.has(hash)) {
245
+ this.geometryStore.set(hash, new Uint8Array(data));
246
+ }
247
+
248
+ // Relay to all clients except sender
249
+ for (const [clientId, client] of this.clients) {
250
+ if (clientId !== senderId) {
251
+ client.send(data);
252
+ }
253
+ }
254
+ }
255
+
256
+ relayMeshRef(senderId: string, data: Uint8Array): void {
257
+ const bodyId = readBodyIdFromMeshRef(data);
258
+ const geoHash = readGeometryHashFromMeshRef(data);
259
+ const matHash = readMaterialHashFromMeshRef(data);
260
+
261
+ // Always store + relay
262
+ this.meshRefStore.set(bodyId, new Uint8Array(data));
263
+
264
+ // Track geometry ref count
265
+ let geoRefs = this.geometryRefCount.get(geoHash);
266
+ if (!geoRefs) {
267
+ geoRefs = new Set();
268
+ this.geometryRefCount.set(geoHash, geoRefs);
269
+ }
270
+ geoRefs.add(bodyId);
271
+
272
+ // Track material ref count
273
+ let matRefs = this.materialRefCount.get(matHash);
274
+ if (!matRefs) {
275
+ matRefs = new Set();
276
+ this.materialRefCount.set(matHash, matRefs);
277
+ }
278
+ matRefs.add(bodyId);
279
+
280
+ // Relay to all clients except sender
281
+ for (const [clientId, client] of this.clients) {
282
+ if (clientId !== senderId) {
283
+ client.send(data);
284
+ }
285
+ }
286
+ }
287
+
288
+ bufferInput(clientId: string, input: import('@rapierphysicsplugin/shared').ClientInput): void {
289
+ const buffer = this.inputBuffers.get(clientId);
290
+ if (buffer) {
291
+ // Map client tick to the current server tick (best-effort for now)
292
+ buffer.addInput(input, this.currentTick);
293
+ }
294
+ }
295
+
296
+ tick(): void {
297
+ // 1. Process buffered inputs for this tick
298
+ for (const [, buffer] of this.inputBuffers) {
299
+ const inputs = buffer.getInputsForTick(this.currentTick);
300
+ for (const input of inputs) {
301
+ for (const action of input.actions) {
302
+ this.physicsWorld.applyInput(action);
303
+ }
304
+ }
305
+ }
306
+
307
+ // 2. Step the physics world and collect collision events
308
+ const collisionEvents = this.physicsWorld.step();
309
+ if (collisionEvents.length > 0) {
310
+ this.pendingCollisionEvents.push(...collisionEvents);
311
+ }
312
+
313
+ // 3. Increment tick
314
+ this.currentTick++;
315
+ this.ticksSinceLastBroadcast++;
316
+
317
+ // 4. Broadcast state at the configured interval
318
+ if (this.ticksSinceLastBroadcast >= BROADCAST_INTERVAL) {
319
+ this.broadcastState();
320
+ this.ticksSinceLastBroadcast = 0;
321
+ }
322
+ }
323
+
324
+ private broadcastState(): void {
325
+ const delta = this.stateManager.createDelta(this.physicsWorld, this.currentTick);
326
+
327
+ if (delta.bodies.length > 0) {
328
+ // Encode directly with ID mapping for numeric body indices
329
+ const message = encodeRoomState(
330
+ {
331
+ type: MessageType.ROOM_STATE,
332
+ tick: delta.tick,
333
+ timestamp: delta.timestamp,
334
+ bodies: delta.bodies,
335
+ isDelta: true,
336
+ },
337
+ this.stateManager.getIdToIndexMap(),
338
+ );
339
+
340
+ this.broadcast(message);
341
+ }
342
+
343
+ if (this.pendingCollisionEvents.length > 0) {
344
+ const collisionMessage = encodeMessage({
345
+ type: MessageType.COLLISION_EVENTS,
346
+ tick: this.currentTick,
347
+ events: this.pendingCollisionEvents,
348
+ });
349
+ this.broadcast(collisionMessage);
350
+ this.pendingCollisionEvents = [];
351
+ }
352
+ }
353
+
354
+ private broadcast(message: Uint8Array): void {
355
+ for (const [, client] of this.clients) {
356
+ client.send(message);
357
+ }
358
+ }
359
+
360
+ getSnapshot(): RoomSnapshot {
361
+ return this.stateManager.createSnapshot(this.physicsWorld, this.currentTick);
362
+ }
363
+
364
+ startSimulation(): void {
365
+ // If already running, stop and reset
366
+ if (this.simulationLoop.isRunning) {
367
+ this.simulationLoop.stop();
368
+ }
369
+
370
+ // Reset physics world to initial state (including constraints)
371
+ this.physicsWorld.reset(this.initialBodies, this.initialConstraints);
372
+ this.currentTick = 0;
373
+ this.ticksSinceLastBroadcast = 0;
374
+ this.pendingCollisionEvents = [];
375
+ this.meshBinaryStore.clear();
376
+ this.geometryStore.clear();
377
+ this.meshRefStore.clear();
378
+ this.geometryRefCount.clear();
379
+ this.materialStore.clear();
380
+ this.textureStore.clear();
381
+ this.materialRefCount.clear();
382
+ this.stateManager.clear();
383
+ for (const [, buffer] of this.inputBuffers) {
384
+ buffer.clear();
385
+ }
386
+
387
+ // Reset active constraints and bodies to initial set
388
+ this.activeConstraints.clear();
389
+ for (const c of this.initialConstraints) {
390
+ this.activeConstraints.set(c.id, c);
391
+ }
392
+ this.activeBodies.clear();
393
+ for (const b of this.initialBodies) {
394
+ this.activeBodies.set(b.id, b);
395
+ }
396
+
397
+ // Start simulation loop
398
+ this.simulationLoop.start();
399
+
400
+ // Broadcast fresh snapshot to all clients (includes updated body ID mapping, constraints, and bodies)
401
+ const snapshot = this.stateManager.createSnapshot(this.physicsWorld, this.currentTick);
402
+ const constraints = Array.from(this.activeConstraints.values());
403
+ const bodies = Array.from(this.activeBodies.values());
404
+ this.broadcast(encodeMessage({
405
+ type: MessageType.SIMULATION_STARTED,
406
+ snapshot,
407
+ bodyIdMap: this.stateManager.getIdToIndexRecord(),
408
+ constraints: constraints.length > 0 ? constraints : undefined,
409
+ bodies: bodies.length > 0 ? bodies : undefined,
410
+ }));
411
+ }
412
+
413
+ get isSimulationRunning(): boolean {
414
+ return this.simulationLoop.isRunning;
415
+ }
416
+
417
+ get clientCount(): number {
418
+ return this.clients.size;
419
+ }
420
+
421
+ get tickNumber(): number {
422
+ return this.currentTick;
423
+ }
424
+
425
+ destroy(): void {
426
+ this.simulationLoop.stop();
427
+ for (const [, client] of this.clients) {
428
+ client.roomId = null;
429
+ }
430
+ this.clients.clear();
431
+ this.inputBuffers.clear();
432
+ this.pendingCollisionEvents = [];
433
+ this.activeConstraints.clear();
434
+ this.activeBodies.clear();
435
+ this.meshBinaryStore.clear();
436
+ this.geometryStore.clear();
437
+ this.meshRefStore.clear();
438
+ this.geometryRefCount.clear();
439
+ this.materialStore.clear();
440
+ this.textureStore.clear();
441
+ this.materialRefCount.clear();
442
+ this.stateManager.clear();
443
+ this.physicsWorld.destroy();
444
+ }
445
+ }
446
+
447
+ export class RoomManager {
448
+ private rooms: Map<string, Room> = new Map();
449
+ private rapier: typeof RAPIER;
450
+
451
+ constructor(rapier: typeof RAPIER) {
452
+ this.rapier = rapier;
453
+ }
454
+
455
+ createRoom(roomId: string, initialBodies: BodyDescriptor[] = [], gravity?: Vec3, initialConstraints?: ConstraintDescriptor[]): Room {
456
+ if (this.rooms.has(roomId)) {
457
+ throw new Error(`Room "${roomId}" already exists`);
458
+ }
459
+
460
+ const room = new Room(roomId, this.rapier, gravity);
461
+ if (initialBodies.length > 0 || (initialConstraints && initialConstraints.length > 0)) {
462
+ room.loadInitialState(initialBodies, initialConstraints);
463
+ }
464
+ this.rooms.set(roomId, room);
465
+ return room;
466
+ }
467
+
468
+ getRoom(roomId: string): Room | undefined {
469
+ return this.rooms.get(roomId);
470
+ }
471
+
472
+ destroyRoom(roomId: string): void {
473
+ const room = this.rooms.get(roomId);
474
+ if (room) {
475
+ room.destroy();
476
+ this.rooms.delete(roomId);
477
+ }
478
+ }
479
+
480
+ get roomCount(): number {
481
+ return this.rooms.size;
482
+ }
483
+
484
+ getAllRoomIds(): string[] {
485
+ return Array.from(this.rooms.keys());
486
+ }
487
+ }