@irtio/cli 0.9.0 → 0.10.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.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  STATIC_LIMITS,
3
3
  staticPathProblem
4
- } from "./chunk-WFMRNGO5.js";
4
+ } from "./chunk-YJB5GQHT.js";
5
5
  import {
6
6
  ensureProject,
7
7
  readProjectConfig
@@ -19,6 +19,44 @@ function staticPathProblem(path2) {
19
19
  }
20
20
  return void 0;
21
21
  }
22
+ function staticContentType(path2) {
23
+ const dot = path2.lastIndexOf(".");
24
+ const ext = dot === -1 ? "" : path2.slice(dot + 1).toLowerCase();
25
+ return STATIC_CONTENT_TYPES[ext] ?? "application/octet-stream";
26
+ }
27
+ var STATIC_CONTENT_TYPES = {
28
+ html: "text/html; charset=utf-8",
29
+ htm: "text/html; charset=utf-8",
30
+ css: "text/css; charset=utf-8",
31
+ js: "text/javascript; charset=utf-8",
32
+ mjs: "text/javascript; charset=utf-8",
33
+ map: "application/json; charset=utf-8",
34
+ json: "application/json; charset=utf-8",
35
+ txt: "text/plain; charset=utf-8",
36
+ md: "text/plain; charset=utf-8",
37
+ xml: "application/xml; charset=utf-8",
38
+ svg: "image/svg+xml",
39
+ png: "image/png",
40
+ jpg: "image/jpeg",
41
+ jpeg: "image/jpeg",
42
+ gif: "image/gif",
43
+ webp: "image/webp",
44
+ avif: "image/avif",
45
+ ico: "image/x-icon",
46
+ wasm: "application/wasm",
47
+ woff: "font/woff",
48
+ woff2: "font/woff2",
49
+ ttf: "font/ttf",
50
+ otf: "font/otf",
51
+ mp3: "audio/mpeg",
52
+ ogg: "audio/ogg",
53
+ wav: "audio/wav",
54
+ mp4: "video/mp4",
55
+ webm: "video/webm",
56
+ glb: "model/gltf-binary",
57
+ gltf: "model/gltf+json",
58
+ pdf: "application/pdf"
59
+ };
22
60
 
23
61
  // ../store/src/disk.ts
24
62
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
@@ -477,5 +515,6 @@ export {
477
515
  pruneSaves,
478
516
  STATIC_LIMITS,
479
517
  staticPathProblem,
518
+ staticContentType,
480
519
  DiskStore
481
520
  };
package/dist/deploy.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import {
2
2
  logEnsured,
3
3
  runStaticDeploy
4
- } from "./chunk-BQBOBFBO.js";
4
+ } from "./chunk-VZDBLJQ2.js";
5
5
  import {
6
6
  clientImportsRoom
7
7
  } from "./chunk-TQU6345E.js";
8
- import "./chunk-WFMRNGO5.js";
8
+ import "./chunk-YJB5GQHT.js";
9
9
  import {
10
10
  ensureProject,
11
11
  readProjectConfig
@@ -22,8 +22,9 @@ import {
22
22
  ratingResultsProblem,
23
23
  ratingValueProblem,
24
24
  saveKey,
25
+ staticContentType,
25
26
  verifyAssertion
26
- } from "./chunk-WFMRNGO5.js";
27
+ } from "./chunk-YJB5GQHT.js";
27
28
  import {
28
29
  readProjectConfig
29
30
  } from "./chunk-JL235KIE.js";
@@ -44,7 +45,7 @@ import { existsSync } from "fs";
44
45
  import { watch } from "fs";
45
46
  import { mkdir, rm, writeFile as writeFile2 } from "fs/promises";
46
47
  import { createRequire } from "module";
47
- import * as path from "path";
48
+ import * as path2 from "path";
48
49
  import { fileURLToPath, pathToFileURL as pathToFileURL3 } from "url";
49
50
 
50
51
  // ../supervisor/src/contract.ts
@@ -126,8 +127,8 @@ function delayWithJitter(ms) {
126
127
  return Math.max(0, ms + jitter);
127
128
  }
128
129
  function sleep(ms) {
129
- return new Promise((resolve2) => {
130
- const t = setTimeout(resolve2, ms);
130
+ return new Promise((resolve3) => {
131
+ const t = setTimeout(resolve3, ms);
131
132
  t.unref?.();
132
133
  });
133
134
  }
@@ -2330,9 +2331,9 @@ var WorkerHost = class {
2330
2331
  }
2331
2332
  onMessage(msg) {
2332
2333
  if ((msg.t === "serialized" || msg.t === "inspected" || msg.t === "stats" || msg.t === "recording" || msg.t === "timeline") && msg.reqId !== void 0 && this.pending.has(msg.reqId)) {
2333
- const resolve2 = this.pending.get(msg.reqId);
2334
+ const resolve3 = this.pending.get(msg.reqId);
2334
2335
  this.pending.delete(msg.reqId);
2335
- resolve2?.(msg);
2336
+ resolve3?.(msg);
2336
2337
  return;
2337
2338
  }
2338
2339
  if (msg.t === "crashed") {
@@ -2346,8 +2347,8 @@ var WorkerHost = class {
2346
2347
  if (this.dead) return;
2347
2348
  this.dead = true;
2348
2349
  clearInterval(this.eluTimer);
2349
- for (const [, resolve2] of this.pending) {
2350
- resolve2({ t: "initFailed", reason });
2350
+ for (const [, resolve3] of this.pending) {
2351
+ resolve3({ t: "initFailed", reason });
2351
2352
  }
2352
2353
  this.pending.clear();
2353
2354
  if (this.disposed) return;
@@ -2367,15 +2368,15 @@ var WorkerHost = class {
2367
2368
  request(make, timeoutMs) {
2368
2369
  if (this.dead || this.disposed) return Promise.resolve(void 0);
2369
2370
  const reqId = ++this.reqId;
2370
- return new Promise((resolve2) => {
2371
+ return new Promise((resolve3) => {
2371
2372
  const timer = setTimeout(() => {
2372
2373
  this.pending.delete(reqId);
2373
- resolve2(void 0);
2374
+ resolve3(void 0);
2374
2375
  }, timeoutMs);
2375
2376
  timer.unref?.();
2376
2377
  this.pending.set(reqId, (msg) => {
2377
2378
  clearTimeout(timer);
2378
- resolve2(msg);
2379
+ resolve3(msg);
2379
2380
  });
2380
2381
  this.post(make(reqId));
2381
2382
  });
@@ -2508,10 +2509,10 @@ function codeOf(err) {
2508
2509
  }
2509
2510
  function defaultSetSystemTime(epochMs) {
2510
2511
  if (process.platform !== "linux") return Promise.resolve(false);
2511
- return new Promise((resolve2, reject) => {
2512
+ return new Promise((resolve3, reject) => {
2512
2513
  execFile("date", ["-u", "-s", `@${Math.floor(epochMs / 1e3)}`], (err) => {
2513
2514
  if (err) reject(err);
2514
- else resolve2(true);
2515
+ else resolve3(true);
2515
2516
  });
2516
2517
  });
2517
2518
  }
@@ -2778,12 +2779,12 @@ var SupervisorImpl = class {
2778
2779
  this.schemaHash = this.bundle?.schemaHash;
2779
2780
  this.checkDeclaredSizing();
2780
2781
  this.workerEntry = this.config.workerEntry ?? resolveWorkerEntry();
2781
- await new Promise((resolve2, reject) => {
2782
+ await new Promise((resolve3, reject) => {
2782
2783
  this.http.once("error", reject);
2783
2784
  this.http.listen(this.config.port, this.config.host ?? "127.0.0.1", () => {
2784
2785
  const address = this.http.address();
2785
2786
  this.port = typeof address === "object" && address !== null ? address.port : this.config.port;
2786
- resolve2();
2787
+ resolve3();
2787
2788
  });
2788
2789
  });
2789
2790
  this.pingTimer = setInterval(() => this.keepalive(), WS_PING_MS);
@@ -2899,8 +2900,8 @@ var SupervisorImpl = class {
2899
2900
  */
2900
2901
  async assignedRooms(candidates) {
2901
2902
  const shard = this.config.shard ?? 0;
2902
- const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((resolve2) => {
2903
- const t = setTimeout(resolve2, ms);
2903
+ const sleep2 = this.config.shardRetrySleep ?? ((ms) => new Promise((resolve3) => {
2904
+ const t = setTimeout(resolve3, ms);
2904
2905
  t.unref?.();
2905
2906
  }));
2906
2907
  let delayMs = SHARD_ASSIGNMENT_RETRY_MS[0];
@@ -3039,8 +3040,8 @@ var SupervisorImpl = class {
3039
3040
  await room.worker?.terminate();
3040
3041
  room.worker = void 0;
3041
3042
  }
3042
- await new Promise((resolve2) => this.wss.close(() => resolve2()));
3043
- await new Promise((resolve2) => this.http.close(() => resolve2()));
3043
+ await new Promise((resolve3) => this.wss.close(() => resolve3()));
3044
+ await new Promise((resolve3) => this.http.close(() => resolve3()));
3044
3045
  }
3045
3046
  // -------------------------------------------------------------------------
3046
3047
  // Logging
@@ -3601,7 +3602,7 @@ var SupervisorImpl = class {
3601
3602
  await loadBundle(ref.bundlePath, ref.version, DEFAULT_ROOM_TYPE3)
3602
3603
  );
3603
3604
  }
3604
- for (const [type, path2] of Object.entries(ref.rooms ?? {})) {
3605
+ for (const [type, path3] of Object.entries(ref.rooms ?? {})) {
3605
3606
  if (!ROOM_TYPE_RE.test(type)) {
3606
3607
  throw new Error(
3607
3608
  `irtio: deployment v${ref.version} names a room type ${JSON.stringify(type)}, which is not a legal type name (lowercase, starting with a letter or digit, up to 24 characters)`
@@ -3610,7 +3611,7 @@ var SupervisorImpl = class {
3610
3611
  if (bundles.has(type)) {
3611
3612
  throw new Error(`irtio: deployment v${ref.version} defines the room type ${type} twice`);
3612
3613
  }
3613
- bundles.set(type, await loadBundle(path2, ref.version, type));
3614
+ bundles.set(type, await loadBundle(path3, ref.version, type));
3614
3615
  }
3615
3616
  const bundle = bundles.get(DEFAULT_ROOM_TYPE3);
3616
3617
  const schemaJson = bundle?.schemaJson ?? ref.schemaJson;
@@ -4126,7 +4127,7 @@ var SupervisorImpl = class {
4126
4127
  const waitMs = this.config.identityKeyWaitMs ?? IDENTITY_KEY_WAIT_MS;
4127
4128
  if (waitMs <= 0) return Promise.resolve();
4128
4129
  if (this.identityKeyWaiters.length >= MAX_IDENTITY_KEY_WAITERS) return Promise.resolve();
4129
- return new Promise((resolve2) => {
4130
+ return new Promise((resolve3) => {
4130
4131
  let done = false;
4131
4132
  const finish = () => {
4132
4133
  if (done) return;
@@ -4134,7 +4135,7 @@ var SupervisorImpl = class {
4134
4135
  clearTimeout(timer);
4135
4136
  const at = this.identityKeyWaiters.indexOf(finish);
4136
4137
  if (at >= 0) this.identityKeyWaiters.splice(at, 1);
4137
- resolve2();
4138
+ resolve3();
4138
4139
  };
4139
4140
  const timer = setTimeout(finish, waitMs);
4140
4141
  timer.unref?.();
@@ -4433,16 +4434,16 @@ var SupervisorImpl = class {
4433
4434
  onDead: (reason) => this.onWorkerDead(room, reason)
4434
4435
  });
4435
4436
  room.worker = worker;
4436
- const outcome = await new Promise((resolve2) => {
4437
+ const outcome = await new Promise((resolve3) => {
4437
4438
  const timer = setTimeout(() => {
4438
4439
  this.readyWaiters.delete(room);
4439
- resolve2(void 0);
4440
+ resolve3(void 0);
4440
4441
  }, this.limits.workerReadyTimeoutMs);
4441
4442
  timer.unref?.();
4442
4443
  this.readyWaiters.set(room, (msg) => {
4443
4444
  clearTimeout(timer);
4444
4445
  this.readyWaiters.delete(room);
4445
- resolve2(msg);
4446
+ resolve3(msg);
4446
4447
  });
4447
4448
  worker.post({
4448
4449
  t: "init",
@@ -5325,16 +5326,16 @@ var SupervisorImpl = class {
5325
5326
  requestJoin(room, session, reconnecting) {
5326
5327
  const worker = room.worker;
5327
5328
  if (!worker) return Promise.resolve(void 0);
5328
- return new Promise((resolve2) => {
5329
+ return new Promise((resolve3) => {
5329
5330
  const timer = setTimeout(() => {
5330
5331
  this.joinWaiters.delete(session.clientId);
5331
- resolve2(void 0);
5332
+ resolve3(void 0);
5332
5333
  }, JOIN_TIMEOUT_MS);
5333
5334
  timer.unref?.();
5334
5335
  this.joinWaiters.set(session.clientId, (msg) => {
5335
5336
  clearTimeout(timer);
5336
5337
  this.joinWaiters.delete(session.clientId);
5337
- resolve2(msg);
5338
+ resolve3(msg);
5338
5339
  });
5339
5340
  worker.post({
5340
5341
  t: "join",
@@ -6104,11 +6105,11 @@ async function loadBundle(bundlePath, version, type) {
6104
6105
  };
6105
6106
  }
6106
6107
  function resolveWorkerEntry() {
6107
- const resolve2 = import.meta.resolve;
6108
- if (typeof resolve2 !== "function") {
6108
+ const resolve3 = import.meta.resolve;
6109
+ if (typeof resolve3 !== "function") {
6109
6110
  throw new Error("irtio: no workerEntry configured and import.meta.resolve is unavailable");
6110
6111
  }
6111
- return resolve2("@irtio/runtime/worker");
6112
+ return resolve3("@irtio/runtime/worker");
6112
6113
  }
6113
6114
  function bundleRefsOf(config) {
6114
6115
  if (config.bundles && config.bundles.length > 0) return config.bundles;
@@ -6277,6 +6278,153 @@ setInterval(tick, 1000);
6277
6278
  </html>
6278
6279
  `;
6279
6280
 
6281
+ // src/dev-serve.ts
6282
+ import { createReadStream } from "fs";
6283
+ import { readFile, stat } from "fs/promises";
6284
+ import * as http from "http";
6285
+ import * as https from "https";
6286
+ import * as path from "path";
6287
+ var IRT_URL_SNIPPET = "<script>window.IRT_URL ??= (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host;</script>";
6288
+ function injectIrtUrl(html) {
6289
+ const head = /<head[^>]*>/i.exec(html);
6290
+ if (head === null) return IRT_URL_SNIPPET + html;
6291
+ const at = head.index + head[0].length;
6292
+ return html.slice(0, at) + IRT_URL_SNIPPET + html.slice(at);
6293
+ }
6294
+ function resolveServedFile(dir, urlPath) {
6295
+ let decoded;
6296
+ try {
6297
+ decoded = decodeURIComponent(urlPath);
6298
+ } catch {
6299
+ return void 0;
6300
+ }
6301
+ if (decoded.includes("\\") || /[\x00-\x1f\x7f]/.test(decoded)) return void 0;
6302
+ const segments = decoded.split("/").filter((s) => s !== "" && s !== ".");
6303
+ if (segments.some((s) => s === ".." || s === ".irtio" || s === "node_modules")) {
6304
+ return void 0;
6305
+ }
6306
+ const file = path.resolve(dir, ...segments);
6307
+ const root = path.resolve(dir);
6308
+ if (file !== root && !file.startsWith(root + path.sep)) return void 0;
6309
+ return file;
6310
+ }
6311
+ function send(res, status, type, body) {
6312
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
6313
+ res.end(body);
6314
+ }
6315
+ function createClientHandler(source, log) {
6316
+ return (req, res) => {
6317
+ const route = (req.url ?? "").split("?")[0] ?? "";
6318
+ if (route === "/healthz" || route === "/__irt" || route.startsWith("/__irt/")) return false;
6319
+ if (source.mode === "proxy") {
6320
+ proxyRequest(source.target, req, res, log);
6321
+ return true;
6322
+ }
6323
+ if (req.method !== "GET" && req.method !== "HEAD") return false;
6324
+ serveFile(source.dir, route, req, res);
6325
+ return true;
6326
+ };
6327
+ }
6328
+ function serveFile(dir, route, req, res) {
6329
+ void (async () => {
6330
+ let file = resolveServedFile(dir, route);
6331
+ if (file === void 0) {
6332
+ send(res, 404, "text/plain; charset=utf-8", "not found");
6333
+ return;
6334
+ }
6335
+ try {
6336
+ let info = await stat(file).catch(() => void 0);
6337
+ if (info?.isDirectory() === true) {
6338
+ file = path.join(file, "index.html");
6339
+ info = await stat(file).catch(() => void 0);
6340
+ }
6341
+ if (info === void 0 || !info.isFile()) {
6342
+ send(res, 404, "text/plain; charset=utf-8", `not found: ${route}
6343
+ (served from ${dir})`);
6344
+ return;
6345
+ }
6346
+ const type = staticContentType(file);
6347
+ if (type.startsWith("text/html")) {
6348
+ const body = injectIrtUrl(await readFile(file, "utf8"));
6349
+ res.writeHead(200, {
6350
+ "content-type": type,
6351
+ "content-length": Buffer.byteLength(body),
6352
+ "cache-control": "no-store"
6353
+ });
6354
+ res.end(req.method === "HEAD" ? void 0 : body);
6355
+ return;
6356
+ }
6357
+ res.writeHead(200, {
6358
+ "content-type": type,
6359
+ "content-length": info.size,
6360
+ // Dev files change constantly; a cached stale bundle is exactly the bug class the
6361
+ // rebuild watcher exists to prevent.
6362
+ "cache-control": "no-store"
6363
+ });
6364
+ if (req.method === "HEAD") {
6365
+ res.end();
6366
+ return;
6367
+ }
6368
+ const stream = createReadStream(file);
6369
+ stream.on("error", () => res.destroy());
6370
+ stream.pipe(res);
6371
+ } catch (err) {
6372
+ send(res, 500, "text/plain; charset=utf-8", String(err));
6373
+ }
6374
+ })();
6375
+ }
6376
+ function proxyRequest(target, req, res, log) {
6377
+ const client = target.protocol === "https:" ? https : http;
6378
+ const upstream = client.request(
6379
+ {
6380
+ hostname: target.hostname,
6381
+ port: target.port === "" ? target.protocol === "https:" ? 443 : 80 : Number(target.port),
6382
+ path: req.url ?? "/",
6383
+ method: req.method ?? "GET",
6384
+ headers: {
6385
+ ...req.headers,
6386
+ host: target.host,
6387
+ // Identity keeps HTML responses injectable. Dev servers on loopback lose nothing.
6388
+ "accept-encoding": "identity"
6389
+ }
6390
+ },
6391
+ (up) => {
6392
+ const type = up.headers["content-type"] ?? "";
6393
+ const injectable = typeof type === "string" && type.includes("text/html") && up.headers["content-encoding"] === void 0;
6394
+ if (!injectable) {
6395
+ res.writeHead(up.statusCode ?? 502, up.headers);
6396
+ up.pipe(res);
6397
+ return;
6398
+ }
6399
+ const chunks = [];
6400
+ up.on("data", (c) => chunks.push(c));
6401
+ up.on("error", () => res.destroy());
6402
+ up.on("end", () => {
6403
+ const body = injectIrtUrl(Buffer.concat(chunks).toString("utf8"));
6404
+ const { "transfer-encoding": _dropped, ...headers } = up.headers;
6405
+ headers["content-length"] = String(Buffer.byteLength(body));
6406
+ res.writeHead(up.statusCode ?? 502, headers);
6407
+ res.end(body);
6408
+ });
6409
+ }
6410
+ );
6411
+ upstream.on("error", (err) => {
6412
+ log(`irtio dev: proxy to ${target.origin} failed: ${String(err)}`);
6413
+ if (!res.headersSent) {
6414
+ send(
6415
+ res,
6416
+ 502,
6417
+ "text/plain; charset=utf-8",
6418
+ `irtio dev could not reach ${target.origin} \u2014 is your dev server running there?`
6419
+ );
6420
+ } else {
6421
+ res.destroy();
6422
+ }
6423
+ });
6424
+ req.pipe(upstream);
6425
+ req.on("error", () => upstream.destroy());
6426
+ }
6427
+
6280
6428
  // src/dev.ts
6281
6429
  var ROOM_CANDIDATES = ["irtio/room.ts", "irtio/room.js", "room.ts"];
6282
6430
  var DEFAULT_PORT = 7070;
@@ -6285,12 +6433,12 @@ var WATCHED_EXTENSIONS = [".ts", ".js", ".mts", ".mjs", ".tsx", ".jsx"];
6285
6433
  var LOG_LIMIT = 100;
6286
6434
  function resolveEntry(cwd, room) {
6287
6435
  if (room !== void 0) {
6288
- const file = path.resolve(cwd, room);
6436
+ const file = path2.resolve(cwd, room);
6289
6437
  if (!existsSync(file)) throw new Error(`irtio dev: no room file at ${file}`);
6290
6438
  return file;
6291
6439
  }
6292
6440
  for (const candidate of ROOM_CANDIDATES) {
6293
- const file = path.resolve(cwd, candidate);
6441
+ const file = path2.resolve(cwd, candidate);
6294
6442
  if (existsSync(file)) return file;
6295
6443
  }
6296
6444
  throw new Error(
@@ -6302,11 +6450,11 @@ function resolveEntry(cwd, room) {
6302
6450
  async function warnIfClientImportsRoom(cwd, entry, config, log) {
6303
6451
  const client = config.client;
6304
6452
  if (client === void 0) return;
6305
- const clientFile = path.resolve(cwd, client);
6453
+ const clientFile = path2.resolve(cwd, client);
6306
6454
  if (await clientImportsRoom(clientFile, entry)) {
6307
6455
  log(
6308
6456
  pc.yellow(
6309
- `irtio: WARNING: the client entry ${client} imports the room file ${path.relative(cwd, entry)} \u2014 room code must never ship to clients; move shared code (e.g. the physics world builder) into its own module both sides import`
6457
+ `irtio: WARNING: the client entry ${client} imports the room file ${path2.relative(cwd, entry)} \u2014 room code must never ship to clients; move shared code (e.g. the physics world builder) into its own module both sides import`
6310
6458
  )
6311
6459
  );
6312
6460
  }
@@ -6314,7 +6462,7 @@ async function warnIfClientImportsRoom(cwd, entry, config, log) {
6314
6462
  function engineAbsolutePlugin(fromPackageDir, pkg) {
6315
6463
  let resolved;
6316
6464
  try {
6317
- resolved = createRequire(path.join(fromPackageDir, "package.json")).resolve(pkg);
6465
+ resolved = createRequire(path2.join(fromPackageDir, "package.json")).resolve(pkg);
6318
6466
  } catch {
6319
6467
  return void 0;
6320
6468
  }
@@ -6339,15 +6487,15 @@ async function resolveWorkerEntry2(outDir) {
6339
6487
  } catch {
6340
6488
  }
6341
6489
  }
6342
- const packagesDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
6343
- const source = path.join(packagesDir, "runtime/src/worker/index.ts");
6490
+ const packagesDir = path2.resolve(path2.dirname(fileURLToPath(import.meta.url)), "../..");
6491
+ const source = path2.join(packagesDir, "runtime/src/worker/index.ts");
6344
6492
  if (!existsSync(source)) {
6345
6493
  throw new Error(
6346
6494
  "irtio dev: could not resolve @irtio/runtime/worker \u2014 is @irtio/runtime installed and built?"
6347
6495
  );
6348
6496
  }
6349
- const outfile = path.join(outDir, "worker.mjs");
6350
- const runtimeDir = path.join(packagesDir, "runtime");
6497
+ const outfile = path2.join(outDir, "worker.mjs");
6498
+ const runtimeDir = path2.join(packagesDir, "runtime");
6351
6499
  const enginePlugins = ["@dimforge/rapier3d-compat", "matter-js", "@dimforge/rapier2d-compat"].map((pkg) => engineAbsolutePlugin(runtimeDir, pkg)).filter((p) => p !== void 0);
6352
6500
  await esbuild.build({
6353
6501
  entryPoints: [source],
@@ -6356,9 +6504,9 @@ async function resolveWorkerEntry2(outDir) {
6356
6504
  platform: "node",
6357
6505
  outfile,
6358
6506
  alias: {
6359
- "@irtio/schema": path.join(packagesDir, "schema/src/index.ts"),
6360
- "@irtio/server": path.join(packagesDir, "server/src/index.ts"),
6361
- "@irtio/protocol": path.join(packagesDir, "protocol/src/index.ts")
6507
+ "@irtio/schema": path2.join(packagesDir, "schema/src/index.ts"),
6508
+ "@irtio/server": path2.join(packagesDir, "server/src/index.ts"),
6509
+ "@irtio/protocol": path2.join(packagesDir, "protocol/src/index.ts")
6362
6510
  },
6363
6511
  ...enginePlugins.length > 0 ? { plugins: enginePlugins } : {},
6364
6512
  // Rapier stays external for the same reason it does in packages/supervisor/test/support.ts:
@@ -6418,21 +6566,21 @@ function safeJson(value) {
6418
6566
  return String(value);
6419
6567
  }
6420
6568
  }
6421
- function send(res, status, type, body) {
6569
+ function send2(res, status, type, body) {
6422
6570
  res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
6423
6571
  res.end(body);
6424
6572
  }
6425
6573
  async function tryCommand(cmd, args) {
6426
6574
  try {
6427
6575
  const { execFile: execFile2 } = await import("child_process");
6428
- return await new Promise((resolve2) => {
6576
+ return await new Promise((resolve3) => {
6429
6577
  const child = execFile2(
6430
6578
  cmd,
6431
6579
  [...args],
6432
6580
  { timeout: PORT_LOOKUP_TIMEOUT_MS, windowsHide: true },
6433
- (err, stdout) => resolve2(err ? void 0 : stdout)
6581
+ (err, stdout) => resolve3(err ? void 0 : stdout)
6434
6582
  );
6435
- child.on("error", () => resolve2(void 0));
6583
+ child.on("error", () => resolve3(void 0));
6436
6584
  });
6437
6585
  } catch {
6438
6586
  return void 0;
@@ -6469,19 +6617,50 @@ async function whoHoldsPort(port) {
6469
6617
  return void 0;
6470
6618
  }
6471
6619
  }
6620
+ function resolveClientSource(cwd, config, options, log) {
6621
+ if (typeof options.serve === "string" && options.proxy !== void 0) {
6622
+ throw new Error("irtio dev: --serve and --proxy are exclusive \u2014 the port has one client side");
6623
+ }
6624
+ if (options.proxy !== void 0) {
6625
+ let target;
6626
+ try {
6627
+ target = new URL(options.proxy);
6628
+ } catch {
6629
+ throw new Error(`irtio dev: --proxy needs a URL, got ${JSON.stringify(options.proxy)}`);
6630
+ }
6631
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
6632
+ throw new Error(`irtio dev: --proxy must be an http(s) URL, got ${options.proxy}`);
6633
+ }
6634
+ return { mode: "proxy", target };
6635
+ }
6636
+ if (typeof options.serve === "string") {
6637
+ const dir2 = path2.resolve(cwd, options.serve);
6638
+ if (!existsSync(dir2)) throw new Error(`irtio dev: --serve: no directory at ${dir2}`);
6639
+ return { mode: "serve", dir: dir2 };
6640
+ }
6641
+ if (options.serve === false || config.static === void 0) return void 0;
6642
+ const dir = path2.resolve(cwd, config.static.dir);
6643
+ if (!existsSync(path2.join(dir, "index.html"))) return void 0;
6644
+ log(
6645
+ pc.dim(
6646
+ `irtio dev: serving ${config.static.dir} (irtio.json "static"); --no-serve turns this off`
6647
+ )
6648
+ );
6649
+ return { mode: "serve", dir };
6650
+ }
6472
6651
  async function startDev(options = {}) {
6473
- const cwd = path.resolve(options.cwd ?? process.cwd());
6652
+ const cwd = path2.resolve(options.cwd ?? process.cwd());
6474
6653
  const startedAt = Date.now();
6475
6654
  const log = options.log ?? ((line) => console.log(line));
6476
6655
  const entry = resolveEntry(cwd, options.room);
6477
6656
  const config = await readProjectConfig(cwd, "irtio dev", options.config);
6478
6657
  const projectId = config.project ?? "dev";
6479
- const outDir = path.join(cwd, ".irtio", "dev");
6658
+ const outDir = path2.join(cwd, ".irtio", "dev");
6480
6659
  const port = options.port ?? DEFAULT_PORT;
6481
- const stateDir = path.join(cwd, ".irtio", "snapshots");
6660
+ const stateDir = path2.join(cwd, ".irtio", "snapshots");
6482
6661
  if (options.resetState === true) {
6483
6662
  await rm(stateDir, { recursive: true, force: true });
6484
- log(pc.dim(`irtio dev: reset ${path.relative(cwd, stateDir)}`));
6663
+ log(pc.dim(`irtio dev: reset ${path2.relative(cwd, stateDir)}`));
6485
6664
  }
6486
6665
  await mkdir(outDir, { recursive: true });
6487
6666
  await warnIfClientImportsRoom(cwd, entry, config, log);
@@ -6499,11 +6678,14 @@ async function startDev(options = {}) {
6499
6678
  mode: first.mode
6500
6679
  };
6501
6680
  const workerEntry = await resolveWorkerEntry2(outDir);
6681
+ const clientSource = resolveClientSource(cwd, config, options, log);
6682
+ const clientHandler = clientSource === void 0 ? void 0 : createClientHandler(clientSource, log);
6502
6683
  const httpHandler = (req, res) => {
6684
+ if (clientHandler?.(req, res) === true) return true;
6503
6685
  if (req.method !== "GET" && req.method !== "HEAD") return false;
6504
6686
  const route = (req.url ?? "").split("?")[0];
6505
6687
  if (route === "/__irt" || route === "/__irt/") {
6506
- send(res, 200, "text/html; charset=utf-8", DEV_PAGE);
6688
+ send2(res, 200, "text/html; charset=utf-8", DEV_PAGE);
6507
6689
  return true;
6508
6690
  }
6509
6691
  if (route === "/__irt/record.json" || route === "/__irt/timeline.json") {
@@ -6518,7 +6700,7 @@ async function startDev(options = {}) {
6518
6700
  ...Number.isInteger(maxTicks) && maxTicks > 0 ? { maxTicks } : {},
6519
6701
  ...Number.isInteger(maxRecords) && maxRecords > 0 ? { maxRecords } : {}
6520
6702
  });
6521
- send(
6703
+ send2(
6522
6704
  res,
6523
6705
  started ? 200 : 404,
6524
6706
  "application/json; charset=utf-8",
@@ -6531,7 +6713,7 @@ async function startDev(options = {}) {
6531
6713
  return;
6532
6714
  }
6533
6715
  const dump = await supervisor.readTimeline(roomId);
6534
- send(
6716
+ send2(
6535
6717
  res,
6536
6718
  dump === void 0 ? 404 : 200,
6537
6719
  "application/json; charset=utf-8",
@@ -6540,7 +6722,7 @@ async function startDev(options = {}) {
6540
6722
  )
6541
6723
  );
6542
6724
  } catch (err) {
6543
- send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6725
+ send2(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6544
6726
  }
6545
6727
  })();
6546
6728
  return true;
@@ -6551,7 +6733,7 @@ async function startDev(options = {}) {
6551
6733
  void (async () => {
6552
6734
  try {
6553
6735
  const saved = await supervisor.saveNow(roomId);
6554
- send(
6736
+ send2(
6555
6737
  res,
6556
6738
  saved === void 0 ? 404 : 200,
6557
6739
  "application/json; charset=utf-8",
@@ -6564,7 +6746,7 @@ async function startDev(options = {}) {
6564
6746
  )
6565
6747
  );
6566
6748
  } catch (err) {
6567
- send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6749
+ send2(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6568
6750
  }
6569
6751
  })();
6570
6752
  return true;
@@ -6572,14 +6754,14 @@ async function startDev(options = {}) {
6572
6754
  if (route === "/__irt/state.json") {
6573
6755
  void (async () => {
6574
6756
  try {
6575
- send(
6757
+ send2(
6576
6758
  res,
6577
6759
  200,
6578
6760
  "application/json; charset=utf-8",
6579
6761
  JSON.stringify(await buildState(supervisor, projectId, bundle, startedAt))
6580
6762
  );
6581
6763
  } catch (err) {
6582
- send(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6764
+ send2(res, 500, "application/json; charset=utf-8", JSON.stringify({ error: String(err) }));
6583
6765
  }
6584
6766
  })();
6585
6767
  return true;
@@ -6592,7 +6774,7 @@ async function startDev(options = {}) {
6592
6774
  bundlePath: bundle.file,
6593
6775
  origins: ["*"],
6594
6776
  port,
6595
- host: "127.0.0.1",
6777
+ host: options.host ?? "127.0.0.1",
6596
6778
  tenantIdleMs: 0,
6597
6779
  store: new DiskStore(stateDir),
6598
6780
  publicUrl: `http://localhost:${port}`,
@@ -6623,7 +6805,7 @@ async function startDev(options = {}) {
6623
6805
  const watcher = options.watch === false ? void 0 : startWatch();
6624
6806
  let stopped = false;
6625
6807
  function startWatch() {
6626
- const dir = path.dirname(entry);
6808
+ const dir = path2.dirname(entry);
6627
6809
  let timer;
6628
6810
  let rebuilding = Promise.resolve();
6629
6811
  let handle;
@@ -6632,7 +6814,7 @@ async function startDev(options = {}) {
6632
6814
  if (filename === null) return;
6633
6815
  const name = filename.toString();
6634
6816
  if (name.split(/[\\/]/).some((p) => p === ".irtio" || p === "node_modules")) return;
6635
- if (!WATCHED_EXTENSIONS.includes(path.extname(name))) return;
6817
+ if (!WATCHED_EXTENSIONS.includes(path2.extname(name))) return;
6636
6818
  if (timer) clearTimeout(timer);
6637
6819
  timer = setTimeout(() => {
6638
6820
  timer = void 0;
@@ -6665,7 +6847,8 @@ async function startDev(options = {}) {
6665
6847
  }
6666
6848
  }
6667
6849
  const boundPort = supervisor.port;
6668
- const devFile = path.join(cwd, ".irtio", "dev.json");
6850
+ const devFile = path2.join(cwd, ".irtio", "dev.json");
6851
+ const clientUrl = clientSource === void 0 ? void 0 : `http://localhost:${boundPort}/`;
6669
6852
  await writeFile2(
6670
6853
  devFile,
6671
6854
  `${JSON.stringify(
@@ -6673,6 +6856,7 @@ async function startDev(options = {}) {
6673
6856
  port: boundPort,
6674
6857
  url: `ws://localhost:${boundPort}`,
6675
6858
  page: `http://localhost:${boundPort}/__irt/`,
6859
+ ...clientUrl !== void 0 ? { client: clientUrl } : {},
6676
6860
  projectId,
6677
6861
  pid: process.pid,
6678
6862
  startedAt: new Date(startedAt).toISOString()
@@ -6686,6 +6870,7 @@ async function startDev(options = {}) {
6686
6870
  return {
6687
6871
  url: `ws://localhost:${boundPort}`,
6688
6872
  page: `http://localhost:${boundPort}/__irt/`,
6873
+ ...clientUrl !== void 0 ? { client: clientUrl } : {},
6689
6874
  port: boundPort,
6690
6875
  projectId,
6691
6876
  supervisor,
@@ -6709,6 +6894,11 @@ port. Rebuilds and resets the rooms whenever the room code changes.
6709
6894
  options:
6710
6895
  --room <file> the room entry (default: irtio/room.ts, irtio/room.js, room.ts)
6711
6896
  --port <n> port to listen on (default 7070; 0 picks a free one)
6897
+ --host <address> address to bind (default 127.0.0.1; 0.0.0.0 for LAN playtests)
6898
+ --serve <dir> serve a static client from <dir> on the same port (default: irtio.json's
6899
+ "static" directory, when it has an index.html)
6900
+ --no-serve do not serve the irtio.json "static" directory
6901
+ --proxy <url> forward non-irtio HTTP to your own dev server (e.g. http://localhost:5173)
6712
6902
  --no-watch do not rebuild on change
6713
6903
  --reset-state delete .irtio/snapshots first, so every room starts fresh
6714
6904
  -c, --config <f> the project file to read (default irtio.json)
@@ -6741,6 +6931,18 @@ function parseDevArgs(args) {
6741
6931
  parsed.port = port;
6742
6932
  break;
6743
6933
  }
6934
+ case "--host":
6935
+ parsed.host = value();
6936
+ break;
6937
+ case "--serve":
6938
+ parsed.serve = value();
6939
+ break;
6940
+ case "--no-serve":
6941
+ parsed.serve = false;
6942
+ break;
6943
+ case "--proxy":
6944
+ parsed.proxy = value();
6945
+ break;
6744
6946
  case "--no-watch":
6745
6947
  parsed.watch = false;
6746
6948
  break;
@@ -6779,8 +6981,11 @@ async function dev(args) {
6779
6981
  ...parsed.resetState ? { resetState: true } : {},
6780
6982
  ...parsed.room !== void 0 ? { room: parsed.room } : {},
6781
6983
  ...parsed.port !== void 0 ? { port: parsed.port } : {},
6984
+ ...parsed.host !== void 0 ? { host: parsed.host } : {},
6782
6985
  ...parsed.config !== void 0 ? { config: parsed.config } : {},
6783
- ...parsed.profile ? { profile: true } : {}
6986
+ ...parsed.profile ? { profile: true } : {},
6987
+ ...parsed.serve !== void 0 ? { serve: parsed.serve } : {},
6988
+ ...parsed.proxy !== void 0 ? { proxy: parsed.proxy } : {}
6784
6989
  });
6785
6990
  } catch (err) {
6786
6991
  if (err instanceof HelpRequested) {
@@ -6793,11 +6998,24 @@ async function dev(args) {
6793
6998
  }
6794
6999
  console.log(server.url);
6795
7000
  console.log(server.page);
7001
+ if (server.client !== void 0) console.log(`${server.client} ${pc.dim("(your game)")}`);
6796
7002
  console.log(
6797
7003
  pc.dim(
6798
7004
  `share links: http://localhost:${server.port}/?room=CODE (codes appear when a client joins)`
6799
7005
  )
6800
7006
  );
7007
+ if (parsed.host !== void 0 && parsed.host !== "127.0.0.1" && parsed.host !== "localhost") {
7008
+ const { networkInterfaces } = await import("os");
7009
+ const lan = Object.values(networkInterfaces()).flat().filter((a) => a !== void 0 && a.family === "IPv4" && !a.internal).map((a) => `${a.address}:${server.port}`);
7010
+ if (server.client !== void 0) {
7011
+ for (const addr of lan) console.log(pc.dim(`LAN: http://${addr}/`));
7012
+ } else {
7013
+ for (const addr of lan) console.log(pc.dim(`LAN: ws://${addr}`));
7014
+ if (lan.length > 0) {
7015
+ console.log(pc.dim(`point LAN pages at it with window.IRT_URL = 'ws://${lan[0]}'`));
7016
+ }
7017
+ }
7018
+ }
6801
7019
  let profileTimer;
6802
7020
  if (parsed.profile) {
6803
7021
  let history = /* @__PURE__ */ new Map();
package/dist/index.js CHANGED
@@ -135,7 +135,7 @@ switch (command) {
135
135
  break;
136
136
  }
137
137
  case "dev": {
138
- const { dev } = await import("./dev-7HQMR46Y.js");
138
+ const { dev } = await import("./dev-6WP6FP5N.js");
139
139
  await dev(args);
140
140
  break;
141
141
  }
@@ -156,7 +156,7 @@ switch (command) {
156
156
  }
157
157
  case "deploy": {
158
158
  if (args.includes("--static")) {
159
- const { staticDeploy } = await import("./static-deploy-7UCYINJB.js");
159
+ const { staticDeploy } = await import("./static-deploy-OHDBVLA7.js");
160
160
  await staticDeploy(args);
161
161
  break;
162
162
  }
package/dist/init.js CHANGED
@@ -15,7 +15,7 @@ import { createInterface } from "readline/promises";
15
15
  import pc from "picocolors";
16
16
 
17
17
  // src/agent-guide.generated.ts
18
- var AGENT_GUIDE = "# irt.io integration guide\n\nThe whole product in one page, written for a context window. Everything below is enough to take a\nsingle-player web game to a deployed, verified multiplayer room without opening a dashboard.\n\n## The mental model\n\nA **room** is a server-side TypeScript file. It owns a small typed **schema**: the state two\nplayers have to agree on, and nothing else about your game. Each client owns its own instances and\nwrites them like local objects. The room's `validate` decides what a write may be. Everything else\nin your game stays where it was. You verify a room the way you verify code. Bots drive real\nsockets, a **scenario** asserts against the server's own recorded timeline, and the same seed gives\nthe same verdict.\n\n## The skeleton\n\nThe smallest project that deploys. Four files, and `irtio init` writes all of them.\n\n```ts\n// irtio/schema.ts\nimport { defineSchema, entity, f32, str, u8 } from '@irtio/schema';\n\nimport { rpc } from './rpc.js';\n\nexport const schema = defineSchema(\n {\n // One instance per connected client, owned by that client. Owned means writable on that\n // client and read-only everywhere else.\n players: entity({ x: f32, y: f32, name: str(24), color: u8 }),\n },\n {\n project: 'p_c0ffee1234abcd56', // written by `irtio init`; public, domain-locked, not a secret\n roles: ['player'] as const,\n rpc,\n },\n);\n```\n\n```ts\n// irtio/rpc.ts\nimport { server, u8 } from '@irtio/schema';\n\n// Typed calls in both directions. `server(...)` is client to server. Names and signatures are\n// part of the schema hash, so skew is caught as a version error instead of at runtime.\nexport const rpc = {\n cheer: server({ params: { volume: u8 } }),\n};\n```\n\n```ts\n// irtio/room.ts\nimport { defineRoom } from '@irtio/server';\n\nimport { schema } from './schema.js';\n\nconst WIDTH = 800;\nconst HEIGHT = 500;\n\nexport default defineRoom(schema, {\n mode: 'tick', // 'event' if nothing moves without a player doing something\n tickRate: 20,\n\n onJoin(state, ctx) {\n if (ctx.reconnecting) return;\n state.players.add(\n ctx.clientId,\n { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },\n { owner: ctx.clientId }, // the line the whole client-side write model rests on\n );\n },\n\n onLeave(state, ctx) {\n state.players.remove(ctx.clientId);\n },\n\n // Owner writes pass through here before they are accepted. Return `next` to accept, `prev` to\n // reject, or a clamped object. This is where cheating stops. Delete it and anything that fits\n // the declared types is accepted.\n validate: {\n players(prev, next) {\n // Refuse what could never be a real value, lock the fields the room assigned at join,\n // and clamp the rest into the world.\n if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;\n if (next.name !== prev.name || next.color !== prev.color) return prev;\n return {\n ...next,\n x: Math.min(WIDTH, Math.max(0, next.x)),\n y: Math.min(HEIGHT, Math.max(0, next.y)),\n };\n },\n },\n\n rpc: {\n cheer(state, params, ctx) {\n if (params.volume > 10) throw new Error('too loud'); // the normal way a room says no\n state.players.get(ctx.clientId)!.color = params.volume;\n },\n },\n\n tick() {}, // tick mode needs one, even empty\n});\n```\n\n```ts\n// your game, three lines added\nimport { joinRoom } from '@irtio/client';\n\nimport { schema } from './irtio/schema.js';\n\nconst room = await joinRoom(schema, { name: 'you' }); // reads ?room=, or creates one\nconst me = room.state.players[room.me]; // yours: write it like a local object\nif (me) me.x = 10;\n\nfor (const [, p] of room.render.players) draw(p); // `render` interpolates everyone else\n```\n\n`room.state` is the authoritative read path for game logic and tests. `room.render` has the same\nshapes and reads non-owned entities a beat behind arrival, interpolated, which is what you draw\nfrom. Writes are batched once per animation frame, and `room.flush()` forces one out now.\n`room.leave()` closes the session. A page rarely needs it; a test with several sessions always\ndoes. `joinRoom`'s options are `{ name, role, url, key, token, physics, transport }`; see\nthe client reference.\n\n`@irtio/client` is a module, so the page that imports it needs whatever bundler your game already\nuses. There is no `irtio build`.\n\n### Write the validate rule your game actually has\n\n`validate` is the one part of the skeleton you cannot copy without thinking. The wrong rule here\nis worse than no rule, because it refuses honest play silently. The server sends a correction, and\nthe thing does not go where the player put it.\n\nA **speed limit** (`if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;`) is\nright when the thing being moved has a movement speed the game defines: a character, a vehicle, a\ndragged token. It is wrong for a **mouse cursor**. A cursor legitimately teleports when the pointer moves fast,\nor when it leaves the window and comes back. A player gains nothing by putting their cursor\nsomewhere.\n\nAsk what a cheat would actually win. Position, for a cursor, wins nothing, so clamp it and stop.\nPosition for a character wins map knowledge and reach, so limit the step. Score, turn order, hit\ndetection and inventory win the game outright, so they should not be client-owned at all: make\nthem `serverOwned` and change them through an RPC.\n\n## The four commands\n\n```bash\nnpx --package @irtio/cli irtio init # scaffold the four files above plus irtio.json\nnpx --package @irtio/cli irtio dev # bundle irtio/room.ts and run it locally\nnpx --package @irtio/cli irtio simulate --scenario irtio/scenario.ts # real bots, your assertions, exit code\nnpx --package @irtio/cli irtio deploy # create the project if new, classify, upload\n```\n\n`simulate` drives real clients at a room that is **already running**, so `dev` stays up in another\nterminal while you run it. The four are a loop, not a pipeline.\n\nEvery subcommand takes `--help`, and the per-command help is the complete list; the top-level\nsummary is a map.\n\n`dev` is the whole server, locally: it bundles the room file and runs it the way the hosted\nserver does, so it is what you develop and test against. It does not build or serve your\n**client**. That stays your own bundler's job, the same one your game already uses.\n\n`deploy` is the hosted path and needs `irtio login`. There is no offline `deploy`. Without an\naccount, `dev` plus your own bundler is as far as you get, and that runs everything below.\n\n`deploy` creates the project on first use and classifies the schema change against the last\ndeployment. It refuses breaking changes unless a migration covers them, then prints where the room\nis playable. `simulate` exits non-zero on a violation, so both belong in CI.\n\nWhen the question is \"why is this room using so much bandwidth\", both commands take `--profile`.\n`dev --profile` prints a per-room breakdown by collection and field once a second.\n`simulate --profile` prints one for the whole run as the bots saw it. The answer is usually a\nsingle wide field, or `overhead` telling you the updates are too small and too frequent. See\n[the profiler](/docs/guides/profiler).\n\n**Restart `dev` after editing the room.** With `--no-watch`, a `dev` you forgot to kill keeps\nserving the old bundle. `simulate` connects to it and reports a clean run against code you have\nreplaced. The report header names the bundle hash and how long the server has been up. Compare\nthose two lines between runs: an edit that changed nothing in the header changed nothing in the\ncode under test.\n\nAn agent driving this through MCP calls `project_create`, `origin_add`, `deploy`, `scenario_run`,\nthen `logs` or `metrics`. Sign in once with `npx --package @irtio/cli irtio login`; the MCP config snippet is a command\nand carries no secret.\n\n## Verify a room with a scenario\n\nA scenario is a TypeScript module next to the room file. Assertions run against the recorded\nauthoritative timeline, one frame per server tick. A scenario can prove something about a race no\nclient could observe, and it replays: same seed, same verdict.\n\n```ts\n// irtio/scenario.ts\nimport { defineScenario } from '@irtio/bots';\n\nimport type { schema } from './schema.js';\n\nexport default defineScenario<typeof schema>({\n bots: 2,\n seconds: 4,\n seed: 41,\n\n // Adversarial in two words: bot 0 lags, and every bot tries illegal writes. `validate` above\n // is what has to refuse them.\n conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),\n cheat: true,\n\n script: async (bot) => {\n await bot.wait(200);\n await bot.room.call.cheer({ volume: 3 });\n await bot.wait(500);\n },\n\n assert: (timeline) => {\n timeline.check('nobody left the arena', () => {\n for (const tick of timeline.ticks) {\n for (const id of timeline.at(tick).players!.ids()) {\n const p = timeline.at(tick).players!.get(id)!;\n if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);\n }\n }\n });\n },\n});\n```\n\n`cheat: true` makes the bots write play-illegal values. If the run reports\n`HOLE bot <n> cheated and drew 0 corrections`, your `validate` accepted them, and that is the\nfinding. `truth: true` additionally saves the room at the end and diffs it against what each client\nactually received.\n\nTwo things to know before you read a result.\n\n**A cheat run that works fails the run by default.** Every refused write is a correction, and the\nbuilt-in `correction-storm` invariant fails above 5 corrections per second per bot. A deliberate\ncheater passes that immediately. Raise it for the run with `--corrections-max 25`, and read the\n`HOLE` line rather than the exit code as the verdict on `validate`.\n\n**The built-in invariants alone prove nothing about `validate`.** They are protocol invariants:\nframes decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room\nthat accepts every illegal write there is passes all of them. Only `cheat` and your own scenario\nassertions test server authority, so a run with neither says nothing about whether your game can be\ncheated.\n\n## One retrofit diff\n\nThe canvas case, the shortest one. The cursors canvas example is this diff as running code; see\n[the retrofit guide](/docs/guides/retrofit).\n\n```diff\n+import { joinRoom } from '@irtio/client';\n+\n+import { schema } from './irtio/schema.js';\n+\n+const room = await joinRoom(schema, { name: 'you' });\n-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };\n-\n canvas.addEventListener('pointermove', (event) => {\n const bounds = canvas.getBoundingClientRect();\n+ const player = room.state.players[room.me];\n+ if (!player) return;\n player.x = event.clientX - bounds.left;\n player.y = event.clientY - bounds.top;\n });\n\n function frame() {\n- const entries = [['me', player]];\n+ const entries = [...room.render.players];\n for (const [, p] of entries) draw(p);\n }\n```\n\nThe whole shape is an import, a join, an entity lookup instead of a local object, and one changed\niteration. The camera, particles, input handling, HUD, draw code and level geometry do not\nmove. If your retrofit touches those, it is doing more than a retrofit. Three.js and Phaser follow\nthe same four steps against their own loops; see the retrofit guide.\n\nYour entity does not exist for the first frame or two after the page loads, which is what\n`if (!player) return;` is for. Writing an instance you do not own is a compile error and a\nwarn-once no-op at runtime.\n\n## When it goes wrong\n\nThe codes you will actually meet, with the fix. The full catalogue is at `/docs/reference/errors`.\n\n| Code | Fix |\n|---|---|\n| `E_AUTH` | The project key was rejected. Check `project` in `irtio/schema.ts` matches the project you are deploying to. On localhost with no `irtio init` the client uses the key `dev`, which only `irtio dev` accepts |\n| `E_SCHEMA_MISMATCH` | The client was built with a different schema than the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client, or deploy the schema the client has |\n| `E_ORIGIN` | The page's origin is not in the project's origin list. Register it. `localhost` is always allowed, so this only bites on a deployed page |\n| `E_ROOM_FULL` | The room is at `maxClients`, 64 by default. Raise it in `irtio/room.ts`, or join with no `?room=` to get a new room |\n| `E_ROOM_NOT_FOUND` | No such room, or an id that cannot be one. Do not construct ids yourself: let `joinRoom` create one and read `room.link` |\n| `E_WRITE_REJECTED` | Your `validate` refused the write, or the value did not fit its declared type. If this is a legitimate move, `validate` is too strict or the field too narrow |\n| `E_NOT_OWNER` | Something wrote an instance it does not own. `await room.requestOwnership(entity, id)` first, and re-read the instance after a grant |\n| `E_RPC_BAD_PARAMS` | Parameters did not match the declared shape. Types normally prevent this, so it means client and server were built from different `rpc.ts` files |\n| `E_RATE_LIMITED` | Read the message. `rate limited` is too many frames: batch instead of calling `room.flush()` in a loop. `too many connections from this address` is the per-IP cap (`connectionsPerIpPerMin`, default 120), which a load run from one machine has to raise |\n| `E_STARTING` | Not an error. The server was asleep and is waking. `room.status === 'starting'`; show a spinner and keep waiting |\n| `E_SLOW_CONSUMER` | The client was not draining its stream and was dropped; it reconnects itself. If it recurs, the room produces more per tick than the connection carries: lower `tickRate`, narrow field types, or use role visibility |\n| `E_CONNECT_FAILED` | Not a protocol code. The socket failed before the join. Read the URL in the message: `ws://localhost:7070` means `irtio dev` is not running |\n| `deploy refused: N breaking changes` | Snapshots cannot be read under the new schema. `irtio migrate create <name>`, write the transform, then `irtio deploy --allow-breaking`. Additive changes (a new field with `.default(...)` or `.opt`) need none of this |\n| `not logged in` | `deploy`, `logs`, `rooms`, `whoami` and `migrate create` need credentials. Run `irtio login` |\n| `HOLE bot <n> cheated and drew 0 corrections` | Not a failure, and the most important warning here. Illegal writes were accepted. Add rules to `validate` |\n\n## Past the skeleton\n\nThe skeleton above is the smallest thing that deploys. Each of these is one docs page\nand none of them changes the model.\n\n- Server-authoritative physics with client prediction: `irtio init --physics`, a shared\n `irtio/world.ts` both sides import. Three engines: Rapier in 3D (`/docs/physics/overview`),\n Rapier in 2D (`/docs/physics/rapier2d`), or matter.js in 2D (`/docs/physics/matter2d`,\n `tickRate: 60`). Recommend Rapier for a new game and reach for matter2d only for a room already\n on it. All three predict, and `room.prediction` reads the same either way\n (`/docs/physics/prediction`).\n- Scripted NPCs that are ordinary client sessions at the protocol level: `room.spawnNPC(...)`\n (`/docs/guides/npcs`).\n- Player storage that outlives a room (`room.kv`), room saves and restores, hibernation, and the\n `retention` option that sets how long a room's state survives\n (`/docs/persistence/player-storage`, `/docs/persistence/saves`, `/docs/concepts/hibernation`).\n- Signed player tokens, roles, and per-role visibility (`/docs/concepts/auth`,\n `/docs/concepts/visibility`).\n- A player identity that survives reconnects, hibernation and a closed tab\n (`/docs/concepts/identity`).\n- Ranked boards per project, written only by your room code\n (`/docs/persistence/leaderboards`).\n- Two strangers matched into a room without either knowing a room code\n (`/docs/concepts/quick-match`).\n- Voice chat in a room, on its own meter (`/docs/concepts/voice`).\n- One of your rooms sending a message to another (`/docs/concepts/room-bus`).\n- A project split across more than one server when a single one is full\n (`/docs/deploy/sharding`).\n\n**Starting from an engine.** Build against the engine's own loop from the first line: React and\nreact-three-fiber (`/docs/integrations/react`), Three.js (`/docs/integrations/threejs`), Phaser\n(`/docs/integrations/phaser`), PixiJS (`/docs/integrations/pixijs`), Babylon.js\n(`/docs/integrations/babylonjs`).\n\n**Moving a game that already exists.** Single-player, which is the diff above at full length:\ncanvas (`/docs/guides/retrofit`), Three.js (`/docs/guides/retrofit-threejs`), Phaser\n(`/docs/guides/retrofit-phaser`). Already multiplayer on something else: Colyseus\n(`/docs/migrate/colyseus`), Playroom (`/docs/migrate/playroom`), Socket.IO\n(`/docs/migrate/socket-io`).\n\nEvery term these pages use is defined once in the glossary (`/docs/reference/glossary`).\n\n## Rules of thumb\n\n- Sync the smallest set of facts two players must agree on. Everything else stays local.\n- A player reporting a fact about themselves is an owned write. Anything a player could gain by\n lying about (scores, deals, turn order, hit detection) is `serverOwned` plus an RPC.\n- Types are budgets, not hints. `f32`, `u8`, `str(24)` are what make an update a handful of bytes.\n- Never import `room.ts` from client code. Shared geometry and constants go in their own module\n that both sides import.\n- Do not paste a credential into a config file. `irtio login` writes one; MCP and the CLI find it.\n- Believe a room when a scenario with an adversarial pass says so, not when it looks right in two\n tabs.\n";
18
+ var AGENT_GUIDE = "# irt.io integration guide\n\nThe whole product in one page, written for a context window. Everything below is enough to take a\nsingle-player web game to a deployed, verified multiplayer room without opening a dashboard.\n\n## The mental model\n\nA **room** is a server-side TypeScript file. It owns a small typed **schema**: the state two\nplayers have to agree on, and nothing else about your game. Each client owns its own instances and\nwrites them like local objects. The room's `validate` decides what a write may be. Everything else\nin your game stays where it was. You verify a room the way you verify code. Bots drive real\nsockets, a **scenario** asserts against the server's own recorded timeline, and the same seed gives\nthe same verdict.\n\n## The skeleton\n\nThe smallest project that deploys. Four files, and `irtio init` writes all of them.\n\n```ts\n// irtio/schema.ts\nimport { defineSchema, entity, f32, str, u8 } from '@irtio/schema';\n\nimport { rpc } from './rpc.js';\n\nexport const schema = defineSchema(\n {\n // One instance per connected client, owned by that client. Owned means writable on that\n // client and read-only everywhere else.\n players: entity({ x: f32, y: f32, name: str(24), color: u8 }),\n },\n {\n project: 'p_c0ffee1234abcd56', // written by `irtio init`; public, domain-locked, not a secret\n roles: ['player'] as const,\n rpc,\n },\n);\n```\n\n```ts\n// irtio/rpc.ts\nimport { server, u8 } from '@irtio/schema';\n\n// Typed calls in both directions. `server(...)` is client to server. Names and signatures are\n// part of the schema hash, so skew is caught as a version error instead of at runtime.\nexport const rpc = {\n cheer: server({ params: { volume: u8 } }),\n};\n```\n\n```ts\n// irtio/room.ts\nimport { defineRoom } from '@irtio/server';\n\nimport { schema } from './schema.js';\n\nconst WIDTH = 800;\nconst HEIGHT = 500;\n\nexport default defineRoom(schema, {\n mode: 'tick', // 'event' if nothing moves without a player doing something\n tickRate: 20,\n\n onJoin(state, ctx) {\n if (ctx.reconnecting) return;\n state.players.add(\n ctx.clientId,\n { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },\n { owner: ctx.clientId }, // the line the whole client-side write model rests on\n );\n },\n\n onLeave(state, ctx) {\n state.players.remove(ctx.clientId);\n },\n\n // Owner writes pass through here before they are accepted. Return `next` to accept, `prev` to\n // reject, or a clamped object. This is where cheating stops. Delete it and anything that fits\n // the declared types is accepted.\n validate: {\n players(prev, next) {\n // Refuse what could never be a real value, lock the fields the room assigned at join,\n // and clamp the rest into the world.\n if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;\n if (next.name !== prev.name || next.color !== prev.color) return prev;\n return {\n ...next,\n x: Math.min(WIDTH, Math.max(0, next.x)),\n y: Math.min(HEIGHT, Math.max(0, next.y)),\n };\n },\n },\n\n rpc: {\n cheer(state, params, ctx) {\n if (params.volume > 10) throw new Error('too loud'); // the normal way a room says no\n state.players.get(ctx.clientId)!.color = params.volume;\n },\n },\n\n tick() {}, // tick mode needs one, even empty\n});\n```\n\n```ts\n// your game, three lines added\nimport { joinRoom } from '@irtio/client';\n\nimport { schema } from './irtio/schema.js';\n\nconst room = await joinRoom(schema, { name: 'you' }); // reads ?room=, or creates one\nconst me = room.state.players[room.me]; // yours: write it like a local object\nif (me) me.x = 10;\n\nfor (const [, p] of room.render.players) draw(p); // `render` interpolates everyone else\n```\n\n`room.state` is the authoritative read path for game logic and tests. `room.render` has the same\nshapes and reads non-owned entities a beat behind arrival, interpolated, which is what you draw\nfrom. Writes are batched once per animation frame, and `room.flush()` forces one out now.\n`room.leave()` closes the session. A page rarely needs it; a test with several sessions always\ndoes. `joinRoom`'s options are `{ name, role, url, key, token, physics, transport }`; see\nthe client reference.\n\n`@irtio/client` is a module, so the page that imports it needs whatever bundler your game already\nuses. There is no `irtio build`.\n\n### Write the validate rule your game actually has\n\n`validate` is the one part of the skeleton you cannot copy without thinking. The wrong rule here\nis worse than no rule, because it refuses honest play silently. The server sends a correction, and\nthe thing does not go where the player put it.\n\nA **speed limit** (`if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;`) is\nright when the thing being moved has a movement speed the game defines: a character, a vehicle, a\ndragged token. It is wrong for a **mouse cursor**. A cursor legitimately teleports when the pointer moves fast,\nor when it leaves the window and comes back. A player gains nothing by putting their cursor\nsomewhere.\n\nAsk what a cheat would actually win. Position, for a cursor, wins nothing, so clamp it and stop.\nPosition for a character wins map knowledge and reach, so limit the step. Score, turn order, hit\ndetection and inventory win the game outright, so they should not be client-owned at all: make\nthem `serverOwned` and change them through an RPC.\n\n## The four commands\n\n```bash\nnpx --package @irtio/cli irtio init # scaffold the four files above plus irtio.json\nnpx --package @irtio/cli irtio dev # bundle irtio/room.ts and run it locally\nnpx --package @irtio/cli irtio simulate --scenario irtio/scenario.ts # real bots, your assertions, exit code\nnpx --package @irtio/cli irtio deploy # create the project if new, classify, upload\n```\n\n`simulate` drives real clients at a room that is **already running**, so `dev` stays up in another\nterminal while you run it. The four are a loop, not a pipeline.\n\nEvery subcommand takes `--help`, and the per-command help is the complete list; the top-level\nsummary is a map.\n\n`dev` is the whole server, locally: it bundles the room file and runs it the way the hosted\nserver does, so it is what you develop and test against. It does not build or serve your\n**client**. That stays your own bundler's job, the same one your game already uses.\n\n`deploy` is the hosted path and needs `irtio login`. There is no offline `deploy`. Without an\naccount, `dev` plus your own bundler is as far as you get, and that runs everything below.\n\n`deploy` creates the project on first use and classifies the schema change against the last\ndeployment. It refuses breaking changes unless a migration covers them, then prints where the room\nis playable. `simulate` exits non-zero on a violation, so both belong in CI.\n\nWhen the question is \"why is this room using so much bandwidth\", both commands take `--profile`.\n`dev --profile` prints a per-room breakdown by collection and field once a second.\n`simulate --profile` prints one for the whole run as the bots saw it. The answer is usually a\nsingle wide field, or `overhead` telling you the updates are too small and too frequent. See\n[the profiler](/docs/guides/profiler).\n\n**Restart `dev` after editing the room.** With `--no-watch`, a `dev` you forgot to kill keeps\nserving the old bundle. `simulate` connects to it and reports a clean run against code you have\nreplaced. The report header names the bundle hash and how long the server has been up. Compare\nthose two lines between runs: an edit that changed nothing in the header changed nothing in the\ncode under test.\n\nAn agent driving this through MCP calls `project_create`, `origin_add`, `deploy`, `scenario_run`,\nthen `logs` or `metrics`. Sign in once with `npx --package @irtio/cli irtio login`; the MCP config snippet is a command\nand carries no secret.\n\n## Verify a room with a scenario\n\nA scenario is a TypeScript module next to the room file. Assertions run against the recorded\nauthoritative timeline, one frame per server tick. A scenario can prove something about a race no\nclient could observe, and it replays: same seed, same verdict.\n\n```ts\n// irtio/scenario.ts\nimport { defineScenario } from '@irtio/bots';\n\nimport type { schema } from './schema.js';\n\nexport default defineScenario<typeof schema>({\n bots: 2,\n seconds: 4,\n seed: 41,\n\n // Adversarial in two words: bot 0 lags, and every bot tries illegal writes. `validate` above\n // is what has to refuse them.\n conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),\n cheat: true,\n\n script: async (bot) => {\n await bot.wait(200);\n await bot.room.call.cheer({ volume: 3 });\n await bot.wait(500);\n },\n\n assert: (timeline) => {\n timeline.check('nobody left the arena', () => {\n for (const tick of timeline.ticks) {\n for (const id of timeline.at(tick).players!.ids()) {\n const p = timeline.at(tick).players!.get(id)!;\n if (p.x < 0 || p.x > 800) throw new Error(`player ${id} at x=${p.x} on tick ${tick}`);\n }\n }\n });\n },\n});\n```\n\n`cheat: true` makes the bots write play-illegal values. If the run reports\n`HOLE bot <n> cheated and drew 0 corrections`, your `validate` accepted them, and that is the\nfinding. `truth: true` additionally saves the room at the end and diffs it against what each client\nactually received.\n\nTwo things to know before you read a result.\n\n**A cheat run that works fails the run by default.** Every refused write is a correction, and the\nbuilt-in `correction-storm` invariant fails above 5 corrections per second per bot. A deliberate\ncheater passes that immediately. Raise it for the run with `--corrections-max 25`, and read the\n`HOLE` line rather than the exit code as the verdict on `validate`.\n\n**The built-in invariants alone prove nothing about `validate`.** They are protocol invariants:\nframes decoded, nobody saw what they should not, bandwidth inside budget, no tick overruns. A room\nthat accepts every illegal write there is passes all of them. Only `cheat` and your own scenario\nassertions test server authority, so a run with neither says nothing about whether your game can be\ncheated.\n\n## One retrofit diff\n\nThe canvas case, the shortest one. The cursors canvas example is this diff as running code; see\n[the retrofit guide](/docs/guides/retrofit).\n\n```diff\n+import { joinRoom } from '@irtio/client';\n+\n+import { schema } from './irtio/schema.js';\n+\n+const room = await joinRoom(schema, { name: 'you' });\n-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };\n-\n canvas.addEventListener('pointermove', (event) => {\n const bounds = canvas.getBoundingClientRect();\n+ const player = room.state.players[room.me];\n+ if (!player) return;\n player.x = event.clientX - bounds.left;\n player.y = event.clientY - bounds.top;\n });\n\n function frame() {\n- const entries = [['me', player]];\n+ const entries = [...room.render.players];\n for (const [, p] of entries) draw(p);\n }\n```\n\nThe whole shape is an import, a join, an entity lookup instead of a local object, and one changed\niteration. The camera, particles, input handling, HUD, draw code and level geometry do not\nmove. If your retrofit touches those, it is doing more than a retrofit. Three.js and Phaser follow\nthe same four steps against their own loops; see the retrofit guide.\n\nYour entity does not exist for the first frame or two after the page loads, which is what\n`if (!player) return;` is for. Writing an instance you do not own is a compile error and a\nwarn-once no-op at runtime.\n\n## When it goes wrong\n\nThe codes you will actually meet, with the fix. The full catalogue is at `/docs/reference/errors`.\n\n| Code | Fix |\n|---|---|\n| `E_AUTH` | The project key was rejected. Check `project` in `irtio/schema.ts` matches the project you are deploying to. On localhost with no `irtio init` the client uses the key `dev`, which only `irtio dev` accepts |\n| `E_SCHEMA_MISMATCH` | The client was built with a different schema than the room is running. The hash covers every field, type, order, role and RPC signature. Rebuild the client, or deploy the schema the client has |\n| `E_ORIGIN` | The page's origin is not in the project's origin list. Register it. `localhost` is always allowed, so this only bites on a deployed page |\n| `E_ROOM_FULL` | The room is at `maxClients`, 64 by default. Raise it in `irtio/room.ts`, or join with no `?room=` to get a new room |\n| `E_ROOM_NOT_FOUND` | No such room, or an id that cannot be one. Do not construct ids yourself: let `joinRoom` create one and read `room.link` |\n| `E_WRITE_REJECTED` | Your `validate` refused the write, or the value did not fit its declared type. If this is a legitimate move, `validate` is too strict or the field too narrow |\n| `E_NOT_OWNER` | Something wrote an instance it does not own. `await room.requestOwnership(entity, id)` first, and re-read the instance after a grant |\n| `E_RPC_BAD_PARAMS` | Parameters did not match the declared shape. Types normally prevent this, so it means client and server were built from different `rpc.ts` files |\n| `E_RATE_LIMITED` | Read the message. `rate limited` is too many frames: batch instead of calling `room.flush()` in a loop. `too many connections from this address` is the per-IP cap (`connectionsPerIpPerMin`, default 120), which a load run from one machine has to raise |\n| `E_STARTING` | Not an error. The server was asleep and is waking. `room.status === 'starting'`; show a spinner and keep waiting |\n| `E_SLOW_CONSUMER` | The client was not draining its stream and was dropped; it reconnects itself. If it recurs, the room produces more per tick than the connection carries: lower `tickRate`, narrow field types, or use role visibility |\n| `E_CONNECT_FAILED` | Not a protocol code. The socket failed before the join. Read the URL in the message: `ws://localhost:7070` means `irtio dev` is not running |\n| `deploy refused: N breaking changes` | Snapshots cannot be read under the new schema. `irtio migrate create <name>`, write the transform, then `irtio deploy --allow-breaking`. Additive changes (a new field with `.default(...)` or `.opt`) need none of this |\n| `not logged in` | `deploy`, `logs`, `rooms`, `whoami` and `migrate create` need credentials. Run `irtio login` |\n| `HOLE bot <n> cheated and drew 0 corrections` | Not a failure, and the most important warning here. Illegal writes were accepted. Add rules to `validate` |\n\n## Past the skeleton\n\nThe skeleton above is the smallest thing that deploys. Each of these is one docs page\nand none of them changes the model.\n\n- Server-authoritative physics with client prediction: `irtio init --physics`, a shared\n `irtio/world.ts` both sides import. Three engines: Rapier in 3D (`/docs/physics/overview`),\n Rapier in 2D (`/docs/physics/rapier2d`), or matter.js in 2D (`/docs/physics/matter2d`,\n `tickRate: 60`). Recommend Rapier for a new game and reach for matter2d only for a room already\n on it. All three predict, and `room.prediction` reads the same either way\n (`/docs/physics/prediction`).\n- Scripted NPCs that are ordinary client sessions at the protocol level: `room.spawnNPC(...)`\n (`/docs/guides/npcs`).\n- Player storage that outlives a room (`room.kv`), room saves and restores, hibernation, and the\n `retention` option that sets how long a room's state survives\n (`/docs/persistence/player-storage`, `/docs/persistence/saves`, `/docs/concepts/hibernation`).\n- Signed player tokens, roles, and per-role visibility (`/docs/concepts/auth`,\n `/docs/concepts/visibility`).\n- A player identity that survives reconnects, hibernation and a closed tab\n (`/docs/concepts/identity`).\n- Ranked boards per project, written only by your room code\n (`/docs/persistence/leaderboards`).\n- Two strangers matched into a room without either knowing a room code\n (`/docs/concepts/quick-match`).\n- Voice chat in a room, on its own meter (`/docs/concepts/voice`).\n- One of your rooms sending a message to another (`/docs/concepts/messages`).\n- A project split across more than one server when a single one is full\n (`/docs/deploy/sharding`).\n\n**Starting from an engine.** Build against the engine's own loop from the first line: React and\nreact-three-fiber (`/docs/integrations/react`), Three.js (`/docs/integrations/threejs`), Phaser\n(`/docs/integrations/phaser`), PixiJS (`/docs/integrations/pixijs`), Babylon.js\n(`/docs/integrations/babylonjs`).\n\n**Moving a game that already exists.** Single-player, which is the diff above at full length:\ncanvas, Three.js and Phaser are all covered in `/docs/guides/retrofit`. Already multiplayer on\nsomething else: Colyseus\n(`/docs/migrate/colyseus`), Playroom (`/docs/migrate/playroom`), Socket.IO\n(`/docs/migrate/socket-io`).\n\nEvery term these pages use is defined once in the glossary (`/docs/reference/glossary`).\n\n## Rules of thumb\n\n- Sync the smallest set of facts two players must agree on. Everything else stays local.\n- A player reporting a fact about themselves is an owned write. Anything a player could gain by\n lying about (scores, deals, turn order, hit detection) is `serverOwned` plus an RPC.\n- Types are budgets, not hints. `f32`, `u8`, `str(24)` are what make an update a handful of bytes.\n- Never import `room.ts` from client code. Shared geometry and constants go in their own module\n that both sides import.\n- Do not paste a credential into a config file. `irtio login` writes one; MCP and the CLI find it.\n- Believe a room when a scenario with an adversarial pass says so, not when it looks right in two\n tabs.\n";
19
19
 
20
20
  // src/init.ts
21
21
  var INIT_DEPENDENCIES = [
@@ -270,9 +270,13 @@ export const bodies = {
270
270
  }),
271
271
  };
272
272
 
273
- /** Intent \u2192 force, once per step. The room's tick() and the client's predictor both call this. */
273
+ /**
274
+ * Intent \u2192 force, once per step. The room's tick() and the client's predictor both call this,
275
+ * with the same four arguments. \`rapier\` and \`world\` are there for world queries \u2014 a ground
276
+ * check via \`world.castRay(new rapier.Ray(...), ...)\`, contact queries, and the like.
277
+ */
274
278
  export const intents = {
275
- players: (body: Body, player: { ax: number; az: number }): void => {
279
+ players: (body: Body, player: { ax: number; az: number }, _rapier: Rapier, _world: World): void => {
276
280
  if (player.ax === 0 && player.az === 0) return;
277
281
  body.applyImpulse({ x: player.ax * NUDGE, y: 0, z: player.az * NUDGE }, true);
278
282
  },
@@ -339,9 +343,11 @@ export default defineRoom(schema, {
339
343
  },
340
344
 
341
345
  tick(state, _dt, room) {
346
+ // Same four arguments the client's predictor passes, so shared hooks can query the world.
347
+ const { rapier, world } = room.physics;
342
348
  for (const [id, player] of state.players) {
343
349
  const body = room.physics.body('players', id);
344
- if (body) intents.players(body, player);
350
+ if (body) intents.players(body, player, rapier, world);
345
351
  }
346
352
  },
347
353
  });
@@ -4,8 +4,8 @@ import {
4
4
  runStaticDeploy,
5
5
  staticDeploy,
6
6
  walkStaticDir
7
- } from "./chunk-BQBOBFBO.js";
8
- import "./chunk-WFMRNGO5.js";
7
+ } from "./chunk-VZDBLJQ2.js";
8
+ import "./chunk-YJB5GQHT.js";
9
9
  import "./chunk-JL235KIE.js";
10
10
  import "./chunk-OCVALOGK.js";
11
11
  import "./chunk-IDF46P7R.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@irtio/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "irtio CLI: local dev server, room scaffolding, bot simulation, deploy, logs and rooms",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -45,12 +45,12 @@
45
45
  "esbuild": "^0.27.3",
46
46
  "picocolors": "^1.1.1",
47
47
  "ws": "^8.18.0",
48
- "@irtio/bots": "0.9.0",
49
- "@irtio/client": "0.9.0",
50
- "@irtio/protocol": "0.9.0",
51
- "@irtio/runtime": "0.9.0",
52
- "@irtio/schema": "0.9.0",
53
- "@irtio/server": "0.9.0"
48
+ "@irtio/bots": "0.10.0",
49
+ "@irtio/client": "0.10.0",
50
+ "@irtio/protocol": "0.10.0",
51
+ "@irtio/runtime": "0.10.0",
52
+ "@irtio/schema": "0.10.0",
53
+ "@irtio/server": "0.10.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/ws": "^8.5.13",