@downdraft/engine 0.3.0 → 0.3.1

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 (82) hide show
  1. package/app/src/renderer/bootstrap.ts +0 -6
  2. package/app/src/renderer/feature-log.ts +1 -9
  3. package/app/src/renderer/mcp-harness.ts +8 -11
  4. package/app/src/renderer/standard-automation-tools.ts +8 -145
  5. package/core/src/module/host.ts +3 -6
  6. package/core/src/module/index.ts +1 -1
  7. package/core/src/module/module.ts +4 -47
  8. package/core/src/physics/interface.ts +5 -0
  9. package/core/src/platform/render-surface.ts +13 -0
  10. package/core/src/render/canvas-resize-watcher.ts +1 -10
  11. package/core/src/render/game-renderer.ts +40 -18
  12. package/core/src/render/index.ts +0 -2
  13. package/core/src/render/input-manager.ts +7 -7
  14. package/core/src/render/renderer-module-host.ts +2 -4
  15. package/core/src/ui-font-scale.ts +5 -38
  16. package/core/src/worker/rpc.ts +65 -0
  17. package/dist/.dd-manifest.json +859 -884
  18. package/dist/app/src/renderer/bootstrap.js +0 -4
  19. package/dist/app/src/renderer/feature-log.js +1 -10
  20. package/dist/app/src/renderer/mcp-harness.js +8 -11
  21. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  22. package/dist/core/src/module/host.js +3 -6
  23. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  24. package/dist/core/src/render/game-renderer.js +44 -18
  25. package/dist/core/src/render/index.js +0 -1
  26. package/dist/core/src/render/input-manager.js +7 -7
  27. package/dist/core/src/render/renderer-module-host.js +2 -4
  28. package/dist/core/src/ui-font-scale.js +5 -38
  29. package/dist/core/src/worker/rpc.js +59 -0
  30. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  31. package/dist/libraries/devtools/src/native-providers.js +0 -2
  32. package/dist/libraries/entities/src/model-renderer.js +87 -27
  33. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  34. package/dist/libraries/networking/src/index.js +1 -0
  35. package/dist/libraries/networking/src/ws-server.js +387 -0
  36. package/dist/libraries/physics-rapier/src/api.js +15 -0
  37. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  38. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  39. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  40. package/dist/libraries/sand/src/materials.js +2 -32
  41. package/dist/libraries/sand/src/sand-world.js +0 -3
  42. package/dist/modules/devtools/src/api.js +8 -23
  43. package/dist/modules/devtools/src/data-bridge.js +0 -11
  44. package/dist/modules/devtools/src/index.js +0 -6
  45. package/dist/modules/devtools/src/init.js +8 -42
  46. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  47. package/dist/modules/devtools/src/worker-sync.js +3 -11
  48. package/dist/test/src/vision-test.js +1 -3
  49. package/libraries/blitz-ui/src/native-module.ts +8 -1
  50. package/libraries/devtools/src/native-providers.ts +0 -2
  51. package/libraries/entities/src/model-renderer.ts +88 -27
  52. package/libraries/entities/src/shaders/model.wgsl +13 -4
  53. package/libraries/networking/src/index.ts +4 -0
  54. package/libraries/networking/src/ws-server.ts +397 -0
  55. package/libraries/physics-rapier/src/api.ts +15 -0
  56. package/libraries/physics-rapier/src/backend.ts +15 -0
  57. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  58. package/libraries/postfx/src/post-process-stack.ts +1 -2
  59. package/libraries/sand/src/materials.ts +2 -32
  60. package/libraries/sand/src/sand-world.ts +0 -4
  61. package/modules/devtools/README.md +1 -3
  62. package/modules/devtools/src/api.ts +10 -34
  63. package/modules/devtools/src/data-bridge.ts +0 -16
  64. package/modules/devtools/src/index.ts +1 -11
  65. package/modules/devtools/src/init.ts +8 -51
  66. package/modules/devtools/src/scene-inspector.ts +2 -6
  67. package/modules/devtools/src/types.ts +5 -90
  68. package/modules/devtools/src/worker-sync.ts +3 -13
  69. package/package.json +4 -5
  70. package/test/src/vision-test.ts +2 -4
  71. package/core/src/render/sab-canvas-overlay.ts +0 -122
  72. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  73. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  74. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  75. package/dist/modules/devtools/src/label-overlay.js +0 -129
  76. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  77. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  78. package/modules/devtools/src/debug-overlay.ts +0 -238
  79. package/modules/devtools/src/doctor-panel.ts +0 -182
  80. package/modules/devtools/src/label-overlay.ts +0 -154
  81. package/modules/devtools/src/material-stats-panel.ts +0 -188
  82. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -0,0 +1,397 @@
1
+ // ============================================================================
2
+ // ws-server.ts — minimal tri-runtime WebSocket server for host-authoritative
3
+ // LAN/WAN sessions. The networking library ships client transports
4
+ // (WebSocketTransport, WebRTCTransport) but had no listener — this fills the
5
+ // host side so a game instance can accept direct connections without any
6
+ // external service.
7
+ //
8
+ // Runtime selection is capability-based (see platform/runtime.ts):
9
+ // Bun → Bun.serve({ fetch, websocket })
10
+ // Deno → Deno.serve + Deno.upgradeWebSocket
11
+ // Node → node:http + a hand-rolled RFC6455 handshake/framer (pure JS,
12
+ // no native deps — also the fallback for any runtime with node:http)
13
+ //
14
+ // Semantics intentionally mirror the browser WebSocket surface: text frames
15
+ // arrive as `string`, binary frames as `Uint8Array`. Ping/pong/close and
16
+ // fragmented-frame reassembly are handled internally. Message cap matches
17
+ // WebSocketTransport's 16 MiB.
18
+ // ============================================================================
19
+
20
+ import { createLogger } from "@downdraft/engine/util/logger";
21
+
22
+ const log = createLogger();
23
+
24
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
25
+ const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
26
+
27
+ /** One accepted client connection on a {@link WsServer}. */
28
+ export interface WsServerConnection {
29
+ /** Server-assigned connection id (monotonic per server). */
30
+ readonly id: number;
31
+ /** Remote peer "ip:port" or best-effort address string. */
32
+ readonly remoteAddress: string;
33
+ /** Send a text (string) or binary (Uint8Array/ArrayBuffer) frame. */
34
+ send(data: string | Uint8Array | ArrayBuffer): void;
35
+ /** Initiate the close handshake (default code 1000). */
36
+ close(code?: number, reason?: string): void;
37
+ }
38
+
39
+ export interface WebSocketServerHandlers {
40
+ /** A client completed the WS handshake. */
41
+ onConnection?(conn: WsServerConnection): void;
42
+ /** A complete (reassembled) message arrived. */
43
+ onMessage?(conn: WsServerConnection, data: Uint8Array | string): void;
44
+ /** The connection closed (cleanly or via error/timeout). */
45
+ onClose?(conn: WsServerConnection, code: number, reason: string): void;
46
+ }
47
+
48
+ export interface WebSocketServerOptions extends WebSocketServerHandlers {
49
+ /** TCP port to listen on. 0 = ephemeral (read `server.port` after start). */
50
+ port: number;
51
+ /** Bind address — default "0.0.0.0" (all interfaces, for LAN/WAN). */
52
+ host?: string;
53
+ }
54
+
55
+ export interface WsServer {
56
+ /** The port actually bound (differs from opts.port when 0 was passed). */
57
+ readonly port: number;
58
+ /** The bound hostname. */
59
+ readonly host: string;
60
+ /** All currently-open connections. */
61
+ connections(): WsServerConnection[];
62
+ /** Stop listening and close every connection. */
63
+ close(): Promise<void>;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Shared plumbing
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface InternalConn extends WsServerConnection {
71
+ /** Runtime-specific raw socket/sendable handle. */
72
+ raw: unknown;
73
+ }
74
+
75
+ function makeServerBase(handlers: WebSocketServerHandlers) {
76
+ const conns = new Set<InternalConn>();
77
+ let nextId = 1;
78
+
79
+ const deliver = (conn: InternalConn, data: Uint8Array | string) => {
80
+ const size = typeof data === "string" ? data.length : data.byteLength;
81
+ if (size > MAX_MESSAGE_SIZE) { conn.close(1009, "message too large"); return; }
82
+ try { handlers.onMessage?.(conn, data); } catch (e) { log.error("WsServer", `onMessage threw: ${e}`); }
83
+ };
84
+
85
+ const drop = (conn: InternalConn, code: number, reason: string) => {
86
+ if (!conns.delete(conn)) return;
87
+ try { handlers.onClose?.(conn, code, reason); } catch (e) { log.error("WsServer", `onClose threw: ${e}`); }
88
+ };
89
+
90
+ return { conns, nextId: () => nextId++, deliver, drop };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Bun — Bun.serve with the `websocket` handler map
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function startBunServer(opts: WebSocketServerOptions): Promise<WsServer> {
98
+ const Bun = (globalThis as any).Bun;
99
+ const base = makeServerBase(opts);
100
+
101
+ const server = (Bun.serve as (o: unknown) => { port: number; stop(force?: boolean): Promise<void> })({
102
+ port: opts.port,
103
+ hostname: opts.host ?? "0.0.0.0",
104
+ fetch(req: Request, srv: any) {
105
+ const ip = srv.requestIP(req);
106
+ const conn: InternalConn = {
107
+ id: base.nextId(),
108
+ remoteAddress: `${ip?.address ?? "?"}:${ip?.port ?? "?"}`,
109
+ raw: null,
110
+ send(data: string | Uint8Array | ArrayBuffer) { if (this.raw) (this.raw as any).send(data instanceof ArrayBuffer ? new Uint8Array(data) : data as never); },
111
+ close(code = 1000, reason = "") { if (this.raw) (this.raw as any).close(code, reason); },
112
+ };
113
+ return srv.upgrade(req, { data: { conn } }) ? undefined : new Response("WebSocket upgrade required", { status: 426 });
114
+ },
115
+ websocket: {
116
+ open(ws: any) {
117
+ const conn = ws.data.conn as InternalConn;
118
+ conn.raw = ws;
119
+ base.conns.add(conn);
120
+ try { opts.onConnection?.(conn); } catch (e) { log.error("WsServer", `onConnection threw: ${e}`); }
121
+ },
122
+ message(ws: any, msg: string | ArrayBuffer | Uint8Array) {
123
+ const conn = ws.data.conn as InternalConn;
124
+ base.deliver(conn, typeof msg === "string" ? msg : new Uint8Array(msg as ArrayBufferLike));
125
+ },
126
+ close(ws: any, code: number, reason: string) {
127
+ base.drop(ws.data.conn as InternalConn, code, reason);
128
+ },
129
+ },
130
+ });
131
+
132
+ return Promise.resolve({
133
+ port: server.port as number,
134
+ host: opts.host ?? "0.0.0.0",
135
+ connections: () => [...base.conns],
136
+ async close() {
137
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
138
+ await server.stop(true);
139
+ },
140
+ });
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Deno — Deno.serve + Deno.upgradeWebSocket
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function startDenoServer(opts: WebSocketServerOptions): Promise<WsServer> {
148
+ const Deno = (globalThis as any).Deno;
149
+ const base = makeServerBase(opts);
150
+
151
+ const server = Deno.serve(
152
+ { port: opts.port, hostname: opts.host ?? "0.0.0.0" },
153
+ (req: Request, info: { remoteAddr?: { hostname: string; port: number } }) => {
154
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
155
+ return new Response("WebSocket upgrade required", { status: 426 });
156
+ }
157
+ const { socket, response } = Deno.upgradeWebSocket(req);
158
+ const conn: InternalConn = {
159
+ id: base.nextId(),
160
+ remoteAddress: info?.remoteAddr ? `${info.remoteAddr.hostname}:${info.remoteAddr.port}` : "?",
161
+ raw: socket,
162
+ send(data: string | Uint8Array | ArrayBuffer) { try { socket.send(data as never); } catch { /* closing */ } },
163
+ close(code = 1000, reason = "") { try { socket.close(code, reason); } catch { /* already closed */ } },
164
+ };
165
+ socket.binaryType = "arraybuffer";
166
+ socket.onopen = () => {
167
+ base.conns.add(conn);
168
+ try { opts.onConnection?.(conn); } catch (e) { log.error("WsServer", `onConnection threw: ${e}`); }
169
+ };
170
+ socket.onmessage = (e: MessageEvent) => {
171
+ base.deliver(conn, typeof e.data === "string" ? e.data : new Uint8Array(e.data as ArrayBuffer));
172
+ };
173
+ socket.onclose = (e: CloseEvent) => base.drop(conn, e.code, e.reason);
174
+ socket.onerror = () => base.drop(conn, 1006, "socket error");
175
+ return response;
176
+ },
177
+ );
178
+
179
+ const bound = server.addr as { port: number; hostname: string };
180
+ return Promise.resolve({
181
+ port: bound.port,
182
+ host: bound.hostname,
183
+ connections: () => [...base.conns],
184
+ async close() {
185
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
186
+ await server.shutdown();
187
+ },
188
+ });
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Node (and generic fallback) — node:http + hand-rolled RFC6455
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /** Encode one server→client frame. Server frames are never masked. */
196
+ function encodeWsFrame(opcode: number, payload: Uint8Array): Uint8Array {
197
+ const len = payload.byteLength;
198
+ let header: Uint8Array;
199
+ if (len < 126) {
200
+ header = new Uint8Array(2);
201
+ header[1] = len;
202
+ } else if (len <= 0xffff) {
203
+ header = new Uint8Array(4);
204
+ header[1] = 126;
205
+ header[2] = len >>> 8; header[3] = len & 0xff;
206
+ } else {
207
+ header = new Uint8Array(10);
208
+ header[1] = 127;
209
+ const dv = new DataView(header.buffer);
210
+ dv.setUint32(2, Math.floor(len / 0x100000000));
211
+ dv.setUint32(6, len >>> 0);
212
+ }
213
+ header[0] = 0x80 | (opcode & 0x0f);
214
+ const out = new Uint8Array(header.length + len);
215
+ out.set(header, 0);
216
+ out.set(payload, header.length);
217
+ return out;
218
+ }
219
+
220
+ const OP_CONT = 0x0, OP_TEXT = 0x1, OP_BIN = 0x2, OP_CLOSE = 0x8, OP_PING = 0x9, OP_PONG = 0xa;
221
+
222
+ async function startNodeServer(opts: WebSocketServerOptions): Promise<WsServer> {
223
+ const http = await import("node:http");
224
+ const crypto = await import("node:crypto");
225
+ const base = makeServerBase(opts);
226
+
227
+ const httpServer = http.createServer((_req, res) => {
228
+ res.writeHead(426, { "Content-Type": "text/plain" });
229
+ res.end("WebSocket upgrade required");
230
+ });
231
+
232
+ httpServer.on("upgrade", (req: import("node:http").IncomingMessage, socket: import("node:net").Socket) => {
233
+ socket.setNoDelay(true);
234
+ const key = req.headers["sec-websocket-key"];
235
+ const upgrade = req.headers.upgrade?.toLowerCase();
236
+ if (upgrade !== "websocket" || typeof key !== "string") {
237
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
238
+ socket.destroy();
239
+ return;
240
+ }
241
+ const accept = crypto.createHash("sha1").update(key + WS_GUID).digest("base64");
242
+ socket.write(
243
+ "HTTP/1.1 101 Switching Protocols\r\n" +
244
+ "Upgrade: websocket\r\n" +
245
+ "Connection: Upgrade\r\n" +
246
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
247
+ );
248
+
249
+ let closed = false;
250
+ const conn: InternalConn = {
251
+ id: base.nextId(),
252
+ remoteAddress: `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`,
253
+ raw: socket,
254
+ send(data: string | Uint8Array | ArrayBuffer) {
255
+ if (closed || socket.destroyed) return;
256
+ if (typeof data === "string") {
257
+ socket.write(Buffer.from(encodeWsFrame(OP_TEXT, Buffer.from(data, "utf8"))));
258
+ } else {
259
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
260
+ socket.write(Buffer.from(encodeWsFrame(OP_BIN, bytes)));
261
+ }
262
+ },
263
+ close(code = 1000, reason = "") {
264
+ if (closed) return;
265
+ closed = true;
266
+ const payload = new Uint8Array(2 + reason.length);
267
+ payload[0] = code >>> 8; payload[1] = code & 0xff;
268
+ payload.set(new TextEncoder().encode(reason), 2);
269
+ try {
270
+ socket.write(Buffer.from(encodeWsFrame(OP_CLOSE, payload)));
271
+ socket.end();
272
+ } catch { /* socket already gone */ }
273
+ },
274
+ };
275
+
276
+ // ── Incremental frame parser ──
277
+ let buf: Buffer = Buffer.alloc(0) as Buffer;
278
+ let fragOp = -1;
279
+ const frags: Buffer[] = [];
280
+
281
+ const finishMessage = (op: number, payload: Buffer) => {
282
+ if (payload.byteLength > MAX_MESSAGE_SIZE) { conn.close(1009, "message too large"); return; }
283
+ if (op === OP_TEXT) base.deliver(conn, payload.toString("utf8"));
284
+ else base.deliver(conn, new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength));
285
+ };
286
+
287
+ const handleFrame = (fin: boolean, op: number, payload: Buffer) => {
288
+ if (op === OP_CLOSE) {
289
+ const code = payload.byteLength >= 2 ? payload.readUInt16BE(0) : 1000;
290
+ const reason = payload.byteLength > 2 ? payload.subarray(2).toString("utf8") : "";
291
+ conn.close(code, reason);
292
+ base.drop(conn, code, reason);
293
+ return;
294
+ }
295
+ if (op === OP_PING) { if (!closed && !socket.destroyed) socket.write(Buffer.from(encodeWsFrame(OP_PONG, payload))); return; }
296
+ if (op === OP_PONG) return;
297
+ if (op === OP_CONT && fragOp < 0) { conn.close(1002, "unexpected continuation"); return; }
298
+ if ((op === OP_TEXT || op === OP_BIN) && fragOp >= 0) { conn.close(1002, "interleaved frame"); return; }
299
+
300
+ if (op !== OP_CONT) fragOp = op;
301
+ frags.push(payload);
302
+ if (!fin) return;
303
+ const full = Buffer.concat(frags);
304
+ frags.length = 0;
305
+ const finalOp = fragOp;
306
+ fragOp = -1;
307
+ finishMessage(finalOp, full);
308
+ };
309
+
310
+ socket.on("data", (chunk: Buffer) => {
311
+ buf = buf.byteLength ? Buffer.concat([buf, chunk]) : chunk;
312
+ // Parse as many complete frames as the buffer holds.
313
+ for (;;) {
314
+ if (buf.byteLength < 2) return;
315
+ const b0 = buf[0], b1 = buf[1];
316
+ const fin = (b0 & 0x80) !== 0;
317
+ const op = b0 & 0x0f;
318
+ const masked = (b1 & 0x80) !== 0;
319
+ let len = b1 & 0x7f;
320
+ let off = 2;
321
+ if (len === 126) {
322
+ if (buf.byteLength < off + 2) return;
323
+ len = buf.readUInt16BE(off); off += 2;
324
+ } else if (len === 127) {
325
+ if (buf.byteLength < off + 8) return;
326
+ const hi = buf.readUInt32BE(off), lo = buf.readUInt32BE(off + 4); off += 8;
327
+ if (hi > 0 || lo > MAX_MESSAGE_SIZE) { conn.close(1009, "message too large"); return; }
328
+ len = lo;
329
+ }
330
+ const maskLen = masked ? 4 : 0;
331
+ if (buf.byteLength < off + maskLen + len) return;
332
+ let payload = buf.subarray(off + maskLen, off + maskLen + len);
333
+ if (masked) {
334
+ const mask = buf.subarray(off, off + 4);
335
+ const unmasked = Buffer.allocUnsafe(payload.byteLength);
336
+ for (let i = 0; i < payload.byteLength; i++) unmasked[i] = payload[i] ^ mask[i & 3];
337
+ payload = unmasked;
338
+ } else {
339
+ payload = Buffer.from(payload); // detach — subarray aliases buf
340
+ }
341
+ buf = buf.subarray(off + maskLen + len);
342
+ handleFrame(fin, op, payload);
343
+ if (closed) return;
344
+ }
345
+ });
346
+
347
+ const onSocketEnd = () => base.drop(conn, 1006, "socket closed");
348
+ socket.on("close", onSocketEnd);
349
+ socket.on("error", onSocketEnd);
350
+
351
+ base.conns.add(conn);
352
+ try { opts.onConnection?.(conn); } catch (e) { log.error("WsServer", `onConnection threw: ${e}`); }
353
+ });
354
+
355
+ await new Promise<void>((resolve, reject) => {
356
+ httpServer.once("error", reject);
357
+ httpServer.listen(opts.port, opts.host ?? "0.0.0.0", () => {
358
+ httpServer.removeListener("error", reject);
359
+ resolve();
360
+ });
361
+ });
362
+
363
+ const addr = httpServer.address();
364
+ const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
365
+
366
+ return {
367
+ port: boundPort,
368
+ host: opts.host ?? "0.0.0.0",
369
+ connections: () => [...base.conns],
370
+ async close() {
371
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
372
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()));
373
+ // httpServer.close waits for open sockets; force-destroy stragglers.
374
+ [...base.conns].forEach((c) => (c.raw as import("node:net").Socket).destroy());
375
+ base.conns.clear();
376
+ },
377
+ };
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Entry point
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Start a WebSocket server. Resolves once the socket is bound and listening.
386
+ * Runtime is picked by capability: Bun.serve → Deno.serve → node:http.
387
+ */
388
+ export function createWebSocketServer(opts: WebSocketServerOptions): Promise<WsServer> {
389
+ const g = globalThis as Record<string, unknown>;
390
+ const bun = g.Bun as { serve?: unknown } | undefined;
391
+ if (typeof bun?.serve === "function") return startBunServer(opts);
392
+ const deno = g.Deno as { serve?: unknown; upgradeWebSocket?: unknown } | undefined;
393
+ if (typeof deno?.serve === "function" && typeof deno.upgradeWebSocket === "function") {
394
+ return startDenoServer(opts);
395
+ }
396
+ return startNodeServer(opts);
397
+ }
@@ -298,6 +298,21 @@ export class UniversalPhysicsAPI {
298
298
  this.backend.setIntegrationDt(realmId, dt);
299
299
  }
300
300
 
301
+ /**
302
+ * Change gravity across all realms (applies to near/mid/far). Best-effort:
303
+ * backends that can't mutate realm gravity omit `setRealmGravity`, in which
304
+ * case callers should fall back to per-body `gravityScale` in BodyDesc.
305
+ */
306
+ setGravity(gravity: [number, number, number]): void {
307
+ [RealmTier.Near, RealmTier.Mid, RealmTier.Far].forEach((tier) => {
308
+ try {
309
+ const realm = this.realmManager.getRealm(tier);
310
+ this.backend.setRealmGravity?.(realm.id, gravity);
311
+ } catch { /* realm tier not initialized */ }
312
+ });
313
+ this.config.gravity = gravity;
314
+ }
315
+
301
316
  // --- Realm ---
302
317
 
303
318
  getRealmTier(body: PhysicsBody): RealmTier {
@@ -472,6 +472,21 @@ export class RapierPhysicsBackend implements PhysicsBackend {
472
472
  }
473
473
  }
474
474
 
475
+ /**
476
+ * Change a live realm's gravity. Updates the stored realm config (the
477
+ * no-lib JS fallback stepper reads it) and forwards to the lib when it
478
+ * supports runtime gravity (the WASM Rapier world.gravity is mutable; the
479
+ * native FFI lib may not implement it — callers should treat this as
480
+ * best-effort).
481
+ */
482
+ setRealmGravity(realmId: number, gravity: [number, number, number]): void {
483
+ const realm = this.realms.get(realmId);
484
+ if (realm) realm.config.gravity = gravity;
485
+ if (this.lib && this.lib.setRealmGravity) {
486
+ this.lib.setRealmGravity(realmId, gravity);
487
+ }
488
+ }
489
+
475
490
  setSleepThresholds(realmId: number, linearThreshold: number, angularThreshold: number): void {
476
491
  if (this.lib) {
477
492
  this.lib.setSleepThresholds(realmId, linearThreshold, angularThreshold);
@@ -7,6 +7,8 @@ const log = createLogger();
7
7
  export interface PhysicsLib {
8
8
  createRealm(id: number, gravity: [number, number, number]): void;
9
9
  destroyRealm(id: number): void;
10
+ /** Change a live realm's gravity (Rapier `world.gravity` is mutable). */
11
+ setRealmGravity?(realmId: number, gravity: [number, number, number]): void;
10
12
  createBody(realmId: number, bodyId: number, desc: BodyDesc, entity: Entity): void;
11
13
  destroyBody(realmId: number, bodyId: number): void;
12
14
  setBodyType(realmId: number, bodyId: number, type: BodyType): void;
@@ -251,6 +253,10 @@ async function doLoadPhysicsLib(): Promise<PhysicsLib> {
251
253
  entityByBodyHandle.delete(id);
252
254
  bodyIdByHandle.delete(id);
253
255
  },
256
+ setRealmGravity(id, gravity) {
257
+ const world = realms.get(id);
258
+ if (world) world.gravity = { x: gravity[0], y: gravity[1], z: gravity[2] };
259
+ },
254
260
  createBody(realmId, bodyId, desc, entity) {
255
261
  const world = realms.get(realmId);
256
262
  if (!world) return;
@@ -546,8 +546,7 @@ export class PostProcessStack {
546
546
  private createGlyphAtlas(): void {
547
547
  const chars = " .:-=+*#%@";
548
548
  const gw = 16, gh = 16, cols = chars.length;
549
- const canvas = document.createElement("canvas");
550
- canvas.width = cols * gw; canvas.height = gh;
549
+ const canvas = new OffscreenCanvas(cols * gw, gh);
551
550
  const ctx = canvas.getContext("2d")!;
552
551
  ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height);
553
552
  ctx.fillStyle = "white"; ctx.font = `bold ${gh - 2}px monospace`;
@@ -71,32 +71,18 @@ export enum Material {
71
71
  // --- Ingredients ---
72
72
  // Ice (frozen water — produced by cooling)
73
73
  Ice = 64,
74
+ // (IDs 67–68, 71–73, 76–77, 79–81, 83–88 were inert alchemy materials —
75
+ // retired; keep the gaps so old saves don't remap onto new materials.)
74
76
  // Liquids
75
77
  Ether = 65, // ethereal solvent — light, volatile, glowing
76
78
  Blood = 66, // viscous, necrotic-tinted
77
- Syrup = 67, // thick, dense, sweet (binding agent)
78
- NightshadeExtract = 68,// toxic liquid
79
79
  TrollBlood = 69, // viscous, healing + necrotic
80
80
  LiquidShadow = 70, // dense, light-absorbing
81
- LoveEssence = 71, // psychic liquid
82
- HateEssence = 72, // psychic liquid, corrosive
83
- DreamMist = 73, // ethereal + psychic, gas-like liquid
84
81
  VoidEssence = 74, // dense, necrotic, reality-bending
85
82
  // Powders (solid, fall)
86
83
  Sulfur = 75, // yellow powder, flammable, corrosive
87
- GroundEyeOfNewt = 76, // psychic powder
88
- GroundBatWing = 77, // ethereal powder
89
84
  BoneDust = 78, // necrotic powder
90
- IronFilings = 79, // kinetic + dense powder, magnetic
91
- MoonstoneDust = 80, // luminous + ethereal powder
92
- CrystalDust = 81, // luminous + kinetic powder
93
85
  MushroomSpores = 82, // toxic + psychic powder, grows
94
- DragonScale = 83, // thermal + kinetic, very dense
95
- PhoenixFeather = 84, // thermal + healing, light
96
- UnicornHorn = 85, // healing + luminous, rare
97
- MandrakeRoot = 86, // toxic + psychic, solid root
98
- SpiderSilk = 87, // kinetic + ethereal, very light
99
- GraveDust = 88, // necrotic + dense
100
86
  StarShard = 89, // luminous + ethereal + kinetic, glowing crystal
101
87
  TimeSand = 90, // kinetic + ethereal, reality-bending
102
88
  // Gases
@@ -283,30 +269,14 @@ export const MATERIALS: Record<number, MaterialDef> = {
283
269
  // --- Liquids ---
284
270
  [Material.Ether]: def(65, "Ether", [0.55, 0.45, 0.85, 0.8], { gravity: 1.5, gravityDir: 1, density: 0.7, liquid: true, flammable: true, burnTime: 40, albedo: 0.2, reflectivity: 0.4, brightness: 1.1 }),
285
271
  [Material.Blood]: def(66, "Blood", [0.7, 0.05, 0.05, 0.95], { gravity: 2, gravityDir: 1, density: 1.06, liquid: true, albedo: 0.3, reflectivity: 0.2, brightness: 0.8 }),
286
- [Material.Syrup]: def(67, "Syrup", [0.6, 0.4, 0.15, 0.95], { gravity: 1, gravityDir: 1, density: 1.4, liquid: true, albedo: 0.3, reflectivity: 0.3, brightness: 0.9 }),
287
- [Material.NightshadeExtract]: def(68, "Nightshade Extract", [0.3, 0.05, 0.35, 0.95], { gravity: 2, gravityDir: 1, density: 1.1, liquid: true, albedo: 0.2, reflectivity: 0.1, brightness: 0.7 }),
288
272
  [Material.TrollBlood]: def(69, "Troll Blood", [0.4, 0.2, 0.15, 0.95], { gravity: 2, gravityDir: 1, density: 1.2, liquid: true, albedo: 0.25, reflectivity: 0.15, brightness: 0.8 }),
289
273
  [Material.LiquidShadow]: def(70, "Liquid Shadow", [0.05, 0.05, 0.12, 0.98], { gravity: 3, gravityDir: 1, density: 2.0, liquid: true, albedo: 0.0, reflectivity: 0.0, brightness: 0.3 }),
290
- [Material.LoveEssence]: def(71, "Love Essence", [0.95, 0.4, 0.6, 0.9], { gravity: 1.5, gravityDir: 1, density: 0.9, liquid: true, albedo: 0.3, reflectivity: 0.4, brightness: 1.0 }),
291
- [Material.HateEssence]: def(72, "Hate Essence", [0.2, 0.1, 0.15, 0.9], { gravity: 2, gravityDir: 1, density: 1.3, liquid: true, albedo: 0.2, reflectivity: 0.2, brightness: 0.7 }),
292
- [Material.DreamMist]: def(73, "Dream Mist", [0.6, 0.7, 0.95, 0.6], { gravity: 1, gravityDir: -1, density: 0.3, liquid: true, albedo: 0.2, reflectivity: 0.5, brightness: 1.2 }),
293
274
  [Material.VoidEssence]: def(74, "Void Essence", [0.1, 0.0, 0.2, 0.95], { gravity: 4, gravityDir: 1, density: 3.0, liquid: true, albedo: 0.0, reflectivity: 0.1, brightness: 0.5 }),
294
275
 
295
276
  // --- Alchemy game: powders (solid, fall) ---
296
277
  [Material.Sulfur]: def(75, "Sulfur", [0.9, 0.85, 0.2, 1.0], { gravity: 1, gravityDir: 1, density: 2.0, solid: true, flammable: true, burnTime: 60, albedo: 0.5, reflectivity: 0.1, brightness: 0.9 }),
297
- [Material.GroundEyeOfNewt]: def(76, "Ground Eye of Newt", [0.5, 0.35, 0.2, 1.0], { gravity: 1, gravityDir: 1, density: 1.2, solid: true, albedo: 0.4, brightness: 0.8 }),
298
- [Material.GroundBatWing]: def(77, "Ground Bat Wing", [0.3, 0.25, 0.35, 1.0], { gravity: 0.8, gravityDir: 1, density: 0.6, solid: true, albedo: 0.3, brightness: 0.7 }),
299
278
  [Material.BoneDust]: def(78, "Bone Dust", [0.85, 0.82, 0.75, 1.0], { gravity: 1, gravityDir: 1, density: 1.5, solid: true, albedo: 0.5, brightness: 0.8 }),
300
- [Material.IronFilings]: def(79, "Iron Filings", [0.55, 0.55, 0.6, 1.0], { gravity: 1.5, gravityDir: 1, density: 7.8, solid: true, magnetic: true, albedo: 0.5, reflectivity: 0.3 }),
301
- [Material.MoonstoneDust]: def(80, "Moonstone Dust", [0.8, 0.85, 0.95, 1.0], { gravity: 1, gravityDir: 1, density: 1.0, solid: true, albedo: 0.4, reflectivity: 0.5, brightness: 1.3 }),
302
- [Material.CrystalDust]: def(81, "Crystal Dust", [0.6, 0.9, 0.95, 1.0], { gravity: 1, gravityDir: 1, density: 2.5, solid: true, albedo: 0.3, reflectivity: 0.6, brightness: 1.2 }),
303
279
  [Material.MushroomSpores]: def(82, "Mushroom Spores", [0.6, 0.5, 0.4, 0.9], { gravity: 0.5, gravityDir: 1, density: 0.4, solid: true, albedo: 0.3, brightness: 0.8 }),
304
- [Material.DragonScale]: def(83, "Dragon Scale", [0.5, 0.15, 0.1, 1.0], { gravity: 1.5, gravityDir: 1, density: 5.0, solid: true, albedo: 0.4, reflectivity: 0.3, brightness: 1.0 }),
305
- [Material.PhoenixFeather]: def(84, "Phoenix Feather", [0.95, 0.5, 0.15, 0.9], { gravity: 0.3, gravityDir: 1, density: 0.3, solid: true, flammable: true, burnTime: 200, albedo: 0.4, brightness: 1.4 }),
306
- [Material.UnicornHorn]: def(85, "Unicorn Horn", [0.95, 0.9, 1.0, 1.0], { gravity: 1, gravityDir: 1, density: 1.8, solid: true, albedo: 0.5, reflectivity: 0.5, brightness: 1.5 }),
307
- [Material.MandrakeRoot]: def(86, "Mandrake Root", [0.5, 0.4, 0.25, 1.0], { gravity: 1, gravityDir: 1, density: 1.3, solid: true, albedo: 0.4, brightness: 0.7 }),
308
- [Material.SpiderSilk]: def(87, "Spider Silk", [0.9, 0.9, 0.85, 0.8], { gravity: 0.2, gravityDir: 1, density: 0.2, solid: true, albedo: 0.4, reflectivity: 0.2, brightness: 0.9 }),
309
- [Material.GraveDust]: def(88, "Grave Dust", [0.3, 0.28, 0.25, 1.0], { gravity: 1, gravityDir: 1, density: 1.8, solid: true, albedo: 0.3, brightness: 0.6 }),
310
280
  [Material.StarShard]: def(89, "Star Shard", [0.7, 0.8, 1.0, 1.0], { gravity: 1, gravityDir: 1, density: 2.0, solid: true, albedo: 0.3, reflectivity: 0.7, brightness: 1.6 }),
311
281
  [Material.TimeSand]: def(90, "Time Sand", [0.4, 0.3, 0.6, 0.9], { gravity: 1, gravityDir: 1, density: 1.5, solid: true, albedo: 0.3, reflectivity: 0.4, brightness: 1.2 }),
312
282
 
@@ -2130,10 +2130,6 @@ export class SandWorld {
2130
2130
  }
2131
2131
  }
2132
2132
 
2133
- // --- PhoenixFeather near fire/lava: glows brighter, resists burning ---
2134
- // (flammable but long burnTime already set; here we just let it shimmer)
2135
- // --- DragonScale: very dense, sinks through liquids (handled by density) ---
2136
-
2137
2133
  // --- LiquidShadow + light source (fire/plasma) → dissipates to smoke ---
2138
2134
  if (mat === Material.LiquidShadow) {
2139
2135
  let lightNi = -1;
@@ -14,12 +14,10 @@ bun add @downdraft/engine/modules/devtools
14
14
  - `BaseSceneInspector`
15
15
  - `DEVTOOLS_REALM`
16
16
  - `DEVTOOLS_SAB_MAX_FEEDS`
17
- - `DebugOverlay`
18
17
  - `DebugRaycast`
19
18
  - `DevToolsDataBridge`
20
19
  - `GridRenderer`
21
20
  - `HeightRulerRenderer`
22
- - `LabelOverlay`
23
- - …and 29 more
21
+ - …and 24 more
24
22
 
25
23
  See the [Downdraft engine repository](https://github.com/knackstedt/downdraft) for architecture docs and examples.