@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/server.ts ADDED
@@ -0,0 +1,312 @@
1
+ import { createServer, type Server as HttpServer } from 'node:http';
2
+ import { WebSocketServer, WebSocket } from 'ws';
3
+ import type RAPIER from '@dimforge/rapier3d-compat';
4
+ import {
5
+ MessageType,
6
+ decodeClientMessage,
7
+ encodeMessage,
8
+ DEFAULT_PORT,
9
+ OPCODE_MESH_BINARY,
10
+ OPCODE_GEOMETRY_DEF,
11
+ OPCODE_MESH_REF,
12
+ OPCODE_MATERIAL_DEF,
13
+ OPCODE_TEXTURE_DEF,
14
+ } from '@rapierphysicsplugin/shared';
15
+ import type { ClientMessage } from '@rapierphysicsplugin/shared';
16
+ import { RoomManager } from './room.js';
17
+ import { ClientConnection } from './client-connection.js';
18
+ import { handleClockSyncRequest } from './clock-sync.js';
19
+
20
+ let clientIdCounter = 0;
21
+ function generateClientId(): string {
22
+ return `client_${++clientIdCounter}`;
23
+ }
24
+
25
+ export class PhysicsServer {
26
+ private httpServer: HttpServer | null = null;
27
+ private wss: WebSocketServer | null = null;
28
+ private connections: Map<string, ClientConnection> = new Map();
29
+ private roomManager: RoomManager;
30
+ private rapier: typeof RAPIER;
31
+
32
+ constructor(rapier: typeof RAPIER) {
33
+ this.rapier = rapier;
34
+ this.roomManager = new RoomManager(rapier);
35
+ }
36
+
37
+ start(port: number = DEFAULT_PORT): Promise<void> {
38
+ return new Promise((resolve) => {
39
+ this.httpServer = createServer((_req, res) => {
40
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
41
+ res.end('Physics server OK');
42
+ });
43
+
44
+ this.wss = new WebSocketServer({ server: this.httpServer });
45
+
46
+ this.wss.on('connection', (ws: WebSocket) => {
47
+ this.handleConnection(ws);
48
+ });
49
+
50
+ this.httpServer.listen(port, () => {
51
+ console.log(`Physics server listening on port ${port}`);
52
+ resolve();
53
+ });
54
+ });
55
+ }
56
+
57
+ private handleConnection(ws: WebSocket): void {
58
+ const clientId = generateClientId();
59
+ const conn = new ClientConnection(clientId, ws);
60
+ this.connections.set(clientId, conn);
61
+
62
+ console.log(`Client connected: ${clientId}`);
63
+
64
+ ws.on('message', (data: Buffer) => {
65
+ try {
66
+ const buf = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
67
+
68
+ // Intercept mesh binary — relay without full decode
69
+ if (buf[0] === OPCODE_MESH_BINARY) {
70
+ if (conn.roomId) {
71
+ const room = this.roomManager.getRoom(conn.roomId);
72
+ if (room) {
73
+ room.relayMeshBinary(conn.id, buf);
74
+ }
75
+ }
76
+ return;
77
+ }
78
+
79
+ // Intercept geometry def — relay/store without full decode
80
+ if (buf[0] === OPCODE_GEOMETRY_DEF) {
81
+ if (conn.roomId) {
82
+ const room = this.roomManager.getRoom(conn.roomId);
83
+ if (room) {
84
+ room.relayGeometryDef(conn.id, buf);
85
+ }
86
+ }
87
+ return;
88
+ }
89
+
90
+ // Intercept mesh ref — relay/store without full decode
91
+ if (buf[0] === OPCODE_MESH_REF) {
92
+ if (conn.roomId) {
93
+ const room = this.roomManager.getRoom(conn.roomId);
94
+ if (room) {
95
+ room.relayMeshRef(conn.id, buf);
96
+ }
97
+ }
98
+ return;
99
+ }
100
+
101
+ // Intercept material def — relay/store without full decode
102
+ if (buf[0] === OPCODE_MATERIAL_DEF) {
103
+ if (conn.roomId) {
104
+ const room = this.roomManager.getRoom(conn.roomId);
105
+ if (room) {
106
+ room.relayMaterialDef(conn.id, buf);
107
+ }
108
+ }
109
+ return;
110
+ }
111
+
112
+ // Intercept texture def — relay/store without full decode
113
+ if (buf[0] === OPCODE_TEXTURE_DEF) {
114
+ if (conn.roomId) {
115
+ const room = this.roomManager.getRoom(conn.roomId);
116
+ if (room) {
117
+ room.relayTextureDef(conn.id, buf);
118
+ }
119
+ }
120
+ return;
121
+ }
122
+
123
+ const message = decodeClientMessage(buf);
124
+ this.handleMessage(conn, message);
125
+ } catch (err) {
126
+ console.error(`Error processing message from ${clientId}:`, err);
127
+ conn.send(encodeMessage({
128
+ type: MessageType.ERROR,
129
+ message: 'Invalid message format',
130
+ }));
131
+ }
132
+ });
133
+
134
+ ws.on('close', () => {
135
+ this.handleDisconnect(conn);
136
+ });
137
+
138
+ ws.on('error', (err) => {
139
+ console.error(`WebSocket error for ${clientId}:`, err);
140
+ });
141
+ }
142
+
143
+ private handleMessage(conn: ClientConnection, message: ClientMessage): void {
144
+ switch (message.type) {
145
+ case MessageType.CLOCK_SYNC_REQUEST:
146
+ handleClockSyncRequest(conn, message);
147
+ break;
148
+
149
+ case MessageType.CREATE_ROOM: {
150
+ try {
151
+ const room = this.roomManager.createRoom(
152
+ message.roomId,
153
+ message.initialBodies,
154
+ message.gravity
155
+ );
156
+ conn.send(encodeMessage({
157
+ type: MessageType.ROOM_CREATED,
158
+ roomId: room.id,
159
+ }));
160
+ } catch (err) {
161
+ conn.send(encodeMessage({
162
+ type: MessageType.ERROR,
163
+ message: (err as Error).message,
164
+ }));
165
+ }
166
+ break;
167
+ }
168
+
169
+ case MessageType.JOIN_ROOM: {
170
+ const room = this.roomManager.getRoom(message.roomId);
171
+ if (!room) {
172
+ conn.send(encodeMessage({
173
+ type: MessageType.ERROR,
174
+ message: `Room "${message.roomId}" not found`,
175
+ }));
176
+ return;
177
+ }
178
+
179
+ // Leave current room if in one
180
+ if (conn.roomId) {
181
+ const currentRoom = this.roomManager.getRoom(conn.roomId);
182
+ currentRoom?.removeClient(conn);
183
+ }
184
+
185
+ room.addClient(conn);
186
+ break;
187
+ }
188
+
189
+ case MessageType.LEAVE_ROOM: {
190
+ if (conn.roomId) {
191
+ const room = this.roomManager.getRoom(conn.roomId);
192
+ room?.removeClient(conn);
193
+ }
194
+ break;
195
+ }
196
+
197
+ case MessageType.CLIENT_INPUT: {
198
+ if (!conn.roomId) return;
199
+ const room = this.roomManager.getRoom(conn.roomId);
200
+ if (room) {
201
+ room.bufferInput(conn.id, message.input);
202
+ }
203
+ break;
204
+ }
205
+
206
+ case MessageType.ADD_BODY: {
207
+ if (!conn.roomId) return;
208
+ const room = this.roomManager.getRoom(conn.roomId);
209
+ if (room) {
210
+ try {
211
+ room.addBody(message.body);
212
+ } catch (err) {
213
+ conn.send(encodeMessage({
214
+ type: MessageType.ERROR,
215
+ message: (err as Error).message,
216
+ }));
217
+ }
218
+ }
219
+ break;
220
+ }
221
+
222
+ case MessageType.REMOVE_BODY: {
223
+ if (!conn.roomId) return;
224
+ const room = this.roomManager.getRoom(conn.roomId);
225
+ if (room) {
226
+ room.removeBody(message.bodyId);
227
+ }
228
+ break;
229
+ }
230
+
231
+ case MessageType.START_SIMULATION: {
232
+ if (!conn.roomId) return;
233
+ const room = this.roomManager.getRoom(conn.roomId);
234
+ if (room) {
235
+ room.startSimulation();
236
+ }
237
+ break;
238
+ }
239
+
240
+ case MessageType.BODY_EVENT: {
241
+ if (!conn.roomId) return;
242
+ // Rebroadcast body events to all clients in the room
243
+ const room = this.roomManager.getRoom(conn.roomId);
244
+ if (room) {
245
+ // The room broadcast is handled by forwarding the message
246
+ // For now, we just log it
247
+ console.log(`Body event from ${conn.id}: ${message.eventType} on ${message.bodyId}`);
248
+ }
249
+ break;
250
+ }
251
+
252
+ case MessageType.ADD_CONSTRAINT: {
253
+ if (!conn.roomId) return;
254
+ const room = this.roomManager.getRoom(conn.roomId);
255
+ if (room) {
256
+ try {
257
+ room.addConstraint(message.constraint);
258
+ } catch (err) {
259
+ conn.send(encodeMessage({
260
+ type: MessageType.ERROR,
261
+ message: (err as Error).message,
262
+ }));
263
+ }
264
+ }
265
+ break;
266
+ }
267
+
268
+ case MessageType.REMOVE_CONSTRAINT: {
269
+ if (!conn.roomId) return;
270
+ const room = this.roomManager.getRoom(conn.roomId);
271
+ if (room) {
272
+ room.removeConstraint(message.constraintId);
273
+ }
274
+ break;
275
+ }
276
+ }
277
+ }
278
+
279
+ private handleDisconnect(conn: ClientConnection): void {
280
+ console.log(`Client disconnected: ${conn.id}`);
281
+
282
+ if (conn.roomId) {
283
+ const room = this.roomManager.getRoom(conn.roomId);
284
+ room?.removeClient(conn);
285
+ }
286
+
287
+ this.connections.delete(conn.id);
288
+ }
289
+
290
+ stop(): void {
291
+ // Destroy all rooms
292
+ for (const roomId of this.roomManager.getAllRoomIds()) {
293
+ this.roomManager.destroyRoom(roomId);
294
+ }
295
+
296
+ // Close all connections
297
+ for (const [, conn] of this.connections) {
298
+ conn.ws.close();
299
+ }
300
+ this.connections.clear();
301
+
302
+ // Close servers
303
+ this.wss?.close();
304
+ this.wss = null;
305
+ this.httpServer?.close();
306
+ this.httpServer = null;
307
+ }
308
+
309
+ getRoomManager(): RoomManager {
310
+ return this.roomManager;
311
+ }
312
+ }
@@ -0,0 +1,48 @@
1
+ import { FIXED_TIMESTEP, BROADCAST_INTERVAL } from '@rapierphysicsplugin/shared';
2
+ import type { Room } from './room.js';
3
+
4
+ export class SimulationLoop {
5
+ private running = false;
6
+ private timer: ReturnType<typeof setInterval> | null = null;
7
+ private lastTime = 0;
8
+ private accumulator = 0;
9
+
10
+ constructor(private room: Room) {}
11
+
12
+ start(): void {
13
+ if (this.running) return;
14
+ this.running = true;
15
+ this.lastTime = performance.now();
16
+ this.accumulator = 0;
17
+
18
+ // Use setInterval at ~1ms for high resolution stepping
19
+ this.timer = setInterval(() => this.update(), 1);
20
+ }
21
+
22
+ stop(): void {
23
+ this.running = false;
24
+ if (this.timer) {
25
+ clearInterval(this.timer);
26
+ this.timer = null;
27
+ }
28
+ }
29
+
30
+ private update(): void {
31
+ const now = performance.now();
32
+ const elapsed = (now - this.lastTime) / 1000; // Convert to seconds
33
+ this.lastTime = now;
34
+
35
+ // Cap elapsed time to prevent spiral of death
36
+ const cappedElapsed = Math.min(elapsed, FIXED_TIMESTEP * 10);
37
+ this.accumulator += cappedElapsed;
38
+
39
+ while (this.accumulator >= FIXED_TIMESTEP) {
40
+ this.room.tick();
41
+ this.accumulator -= FIXED_TIMESTEP;
42
+ }
43
+ }
44
+
45
+ get isRunning(): boolean {
46
+ return this.running;
47
+ }
48
+ }
@@ -0,0 +1,136 @@
1
+ import type { BodyState, RoomSnapshot } from '@rapierphysicsplugin/shared';
2
+ import { FIELD_POSITION, FIELD_ROTATION, FIELD_LIN_VEL, FIELD_ANG_VEL, FIELD_ALL } from '@rapierphysicsplugin/shared';
3
+ import type { PhysicsWorld } from './physics-world.js';
4
+
5
+ export class StateManager {
6
+ private lastBroadcastStates: Map<string, BodyState> = new Map();
7
+ private bodyIdToIndex: Map<string, number> = new Map();
8
+ private bodyIndexToId: Map<number, string> = new Map();
9
+ private nextBodyIndex = 0;
10
+
11
+ createSnapshot(world: PhysicsWorld, tick: number): RoomSnapshot {
12
+ const bodies = world.getSnapshot();
13
+ // Ensure all bodies have an index assigned
14
+ for (const body of bodies) {
15
+ this.ensureBodyIndex(body.id);
16
+ }
17
+ return {
18
+ tick,
19
+ timestamp: Date.now(),
20
+ bodies,
21
+ };
22
+ }
23
+
24
+ createDelta(world: PhysicsWorld, tick: number): RoomSnapshot & { isDelta: boolean } {
25
+ const allBodies = world.getSnapshot();
26
+ const changedBodies: BodyState[] = [];
27
+
28
+ for (const body of allBodies) {
29
+ this.ensureBodyIndex(body.id);
30
+ const prev = this.lastBroadcastStates.get(body.id);
31
+ if (!prev) {
32
+ body.fieldMask = FIELD_ALL;
33
+ changedBodies.push(body);
34
+ } else {
35
+ // Skip comparison for sleeping bodies (their state is unchanged)
36
+ if (world.isBodySleeping(body.id)) continue;
37
+
38
+ const mask = getChangedFields(prev, body);
39
+ if (mask !== 0) {
40
+ body.fieldMask = mask;
41
+ changedBodies.push(body);
42
+ }
43
+ }
44
+ }
45
+
46
+ // Update cache
47
+ for (const body of allBodies) {
48
+ this.lastBroadcastStates.set(body.id, body);
49
+ }
50
+
51
+ // Remove bodies no longer in simulation
52
+ for (const id of this.lastBroadcastStates.keys()) {
53
+ if (!world.hasBody(id)) {
54
+ this.lastBroadcastStates.delete(id);
55
+ }
56
+ }
57
+
58
+ return {
59
+ tick,
60
+ timestamp: Date.now(),
61
+ bodies: changedBodies,
62
+ isDelta: true,
63
+ };
64
+ }
65
+
66
+ ensureBodyIndex(id: string): number {
67
+ let index = this.bodyIdToIndex.get(id);
68
+ if (index === undefined) {
69
+ index = this.nextBodyIndex++;
70
+ this.bodyIdToIndex.set(id, index);
71
+ this.bodyIndexToId.set(index, id);
72
+ }
73
+ return index;
74
+ }
75
+
76
+ getBodyIndex(id: string): number | undefined {
77
+ return this.bodyIdToIndex.get(id);
78
+ }
79
+
80
+ getIdToIndexMap(): Map<string, number> {
81
+ return this.bodyIdToIndex;
82
+ }
83
+
84
+ getIndexToIdMap(): Map<number, string> {
85
+ return this.bodyIndexToId;
86
+ }
87
+
88
+ getIdToIndexRecord(): Record<string, number> {
89
+ const record: Record<string, number> = {};
90
+ for (const [id, index] of this.bodyIdToIndex) {
91
+ record[id] = index;
92
+ }
93
+ return record;
94
+ }
95
+
96
+ removeBody(id: string): void {
97
+ this.lastBroadcastStates.delete(id);
98
+ // Note: we don't remove the index mapping — indices are never reused
99
+ // to prevent desync with clients that haven't processed the removal yet
100
+ }
101
+
102
+ clear(): void {
103
+ this.lastBroadcastStates.clear();
104
+ this.bodyIdToIndex.clear();
105
+ this.bodyIndexToId.clear();
106
+ this.nextBodyIndex = 0;
107
+ }
108
+ }
109
+
110
+ const EPSILON = 0.0001;
111
+
112
+ function vec3Changed(a: { x: number; y: number; z: number }, b: { x: number; y: number; z: number }): boolean {
113
+ return (
114
+ Math.abs(a.x - b.x) > EPSILON ||
115
+ Math.abs(a.y - b.y) > EPSILON ||
116
+ Math.abs(a.z - b.z) > EPSILON
117
+ );
118
+ }
119
+
120
+ function quatChanged(a: { x: number; y: number; z: number; w: number }, b: { x: number; y: number; z: number; w: number }): boolean {
121
+ return (
122
+ Math.abs(a.x - b.x) > EPSILON ||
123
+ Math.abs(a.y - b.y) > EPSILON ||
124
+ Math.abs(a.z - b.z) > EPSILON ||
125
+ Math.abs(a.w - b.w) > EPSILON
126
+ );
127
+ }
128
+
129
+ function getChangedFields(a: BodyState, b: BodyState): number {
130
+ let mask = 0;
131
+ if (vec3Changed(a.position, b.position)) mask |= FIELD_POSITION;
132
+ if (quatChanged(a.rotation, b.rotation)) mask |= FIELD_ROTATION;
133
+ if (vec3Changed(a.linVel, b.linVel)) mask |= FIELD_LIN_VEL;
134
+ if (vec3Changed(a.angVel, b.angVel)) mask |= FIELD_ANG_VEL;
135
+ return mask;
136
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "references": [
9
+ { "path": "../shared" }
10
+ ]
11
+ }