@homie-rocks/studio 0.47.0 → 0.48.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.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,45 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.48.0] - 2026-10-13
21
+
22
+ **Plugin 0.48.0** · Baked physics worlds for server rules and local prediction.
23
+
24
+ Rules games can sweep capsules through a baked GLB with the same deterministic physics on the server and in local prediction.
25
+
26
+ ### Added
27
+
28
+ - Rules games can opt into deterministic Rapier collision through
29
+ `@homie-rocks/physics`: build-time GLB baking, asynchronous initialization before
30
+ ticking, synchronous metered capsule sweeps, casts, rays, overlaps and solver
31
+ steps, and complete physics snapshots saved with the room. Browser movement
32
+ replays against the same baked world. Games without physics retain their
33
+ existing behavior and budget costs.
34
+ - `ctx.substeps(body, hz, callback)` lets a game choose its own integration rate
35
+ within a room tick. Server and browser share the callback and budget; the view
36
+ draws its recorded intermediate poses. The build checks authoritative body
37
+ fields separately from these temporary drawing traces.
38
+ - Release tests compare 220 complete guarded-room hashes across Node, Chrome and
39
+ workerd for eight capsules at 300 Hz on a 1,754-triangle level, restore mid-fall,
40
+ and settle two predictions exactly with 150 ms each way and seeded packet loss.
41
+ The calibration and real two-Chrome-player Octree trial are recorded in
42
+ `docs/physics-048-calibration.md` and `docs/receipts/`.
43
+ - Physics 0.1.2 adds a stateless capsule cast for applications that own integration
44
+ and replay; it uses the package's existing character contact handling.
45
+
46
+ ### Fixed
47
+
48
+ - Predictor callbacks that inspect the room no longer reenter snapshot replay.
49
+ - Provider discovery recognizes existing Homie fal and ElevenLabs key files,
50
+ including the legacy workspace location, without copying or exposing keys.
51
+
52
+ ### Upgrade notes
53
+
54
+ - Physics is opt-in. Install `@homie-rocks/physics` 0.1.2 and name the
55
+ game's static GLB with `map.physics.level`; see the Physics section of the
56
+ rules guide. The complete solver save can exceed browser-host checkpoint caps,
57
+ so the real-mesh example uses server hosting.
58
+
20
59
  ## [0.47.0] - 2026-10-13
21
60
 
22
61
  **Plugin 0.47.0** · Wrapped worlds and generated collision maps.
@@ -404,6 +404,46 @@ map.spots, and `world.sweep/support/overlaps` (also on map). Sweeps test static
404
404
  `20 + 4S` plus copying, writes body.pos, and returns an optional hit.
405
405
  Movement may write pos, vel, heading, grounded and declared motion; never return
406
406
  an updated body. Normalize diagonals and respect maxSpeed. Collision movement needs a sweep; setting velocity alone does not move a body.
407
+
408
+ ### Integrating at the game's own rate
409
+
410
+ Opt in from `move` with `ctx.substeps(body, hz, step)`. For a 20 Hz room,
411
+ 300 Hz runs fifteen integrations inside each tick; 60 Hz runs three. The rate
412
+ must be a positive integer multiple of the room rate. Existing movement is
413
+ unchanged unless it calls this method.
414
+
415
+ ```ts
416
+ export const move = defineMove({
417
+ runner(body, input, ctx) {
418
+ ctx.substeps(body, 300, step => {
419
+ if (step.first && input.jump && body.grounded)
420
+ body.vel = { ...body.vel, z: 8 };
421
+ body.vel = { ...body.vel, z: body.vel.z - 30 * step.dt };
422
+ ctx.map.sweep(body, ctx.math.scale(body.vel, step.dt));
423
+ });
424
+ }
425
+ });
426
+ ```
427
+
428
+ The callback receives `dt` (seconds), zero-based `index`, `count`, `first`
429
+ and end-of-step `fraction`. Use `step.dt` for integration; `ctx.dt` remains
430
+ the full tick. A pulse belongs to the tick: consume it on `step.first` when
431
+ it should act once. Held inputs retain their existing tick-mean semantics;
432
+ this API does not recover the timestamp of changes within a tick. A game
433
+ that already reconstructs held transitions can keep that policy in the
434
+ callback. It does not change Stormbreak's existing integration.
435
+
436
+ One body may be integrated once per move. The call charges 8 units plus
437
+ 64 per pose (the initial pose and each completed substep), before allocating
438
+ the trace. Guarded callback operations and collision queries charge the
439
+ same movement-handler budget as usual. There is no separate quota or
440
+ implicit rate cap.
441
+
442
+ The browser predicts the identical callback and draws between the recorded
443
+ substep poses, including a path that leaves and returns to the same tick
444
+ endpoint. The trace is temporary drawing data: it adds no wire fields or
445
+ save fields. The final body and declared motion fields are saved as before.
446
+ This helper does not add triangle collision or a physics world.
407
447
  Put freezes, boost expiry and knockback in declared motion. Initialize a late arrival
408
448
  in on.arrive too: onRoom.roundStart already happened before it joined.
409
449
 
@@ -1078,6 +1118,15 @@ leaves: take the marker down quietly. `ctx.ray/rays/rayAll` query what the
1078
1118
  player sees (`ctx.viewTick`); `ctx.preview` says the run is between ticks. A
1079
1119
  predictor runs often: keep it cheap, and keep per-entity state in `me`.
1080
1120
 
1121
+ `ctx.each(kind, fn)` reads frozen entities from the authoritative snapshot this
1122
+ prediction started from. Use it for pickups and other snapshot-based decisions;
1123
+ it excludes provisional ghosts. Reading `room.each`, `room.get`, `room.me` or
1124
+ ray queries inside a predictor cannot advance the clock or start a nested
1125
+ preview. Other room entities retain their normal drawn poses; `room.me` reads
1126
+ the authoritative own entity during the callback. Use the callback's `me` for
1127
+ the predicted own state. Snapshot reconciliation also prevents clock pumping
1128
+ through event listeners until replay finishes.
1129
+
1081
1130
  To guess a shot the server will judge with rewind, ask for what the server
1082
1131
  will use (0.46.5). `ctx.judgedTick(aimTick)` is the tick it will pose the others
1083
1132
  at: the `aimTick` the input carries, held to how far back this seat may look
@@ -1245,3 +1294,94 @@ entities remain suitable for independently moving doors, cover and platforms.
1245
1294
  `spawn` exposes a new entity before its deferred `on.arrive` handler. Pass every
1246
1295
  field that must be valid immediately in the spawn data; use `arrive` for subsequent
1247
1296
  work, not for mandatory initialization.
1297
+
1298
+ ## Physics
1299
+
1300
+ Use the opt-in `@homie-rocks/physics` bridge when the level is an arbitrary triangle
1301
+ mesh: caves, ramps, stairs and the static environment of a game such as Octree
1302
+ Arena. Keep `ctx.map` for the kernel's boxes, circles, capsules and height tiles.
1303
+ Install `@homie-rocks/physics` 0.1.2 in the studio. A game that does not
1304
+ ask for physics loads no physics engine and pays no physics units.
1305
+
1306
+ In `map/main.json`, name a static GLB relative to the game directory:
1307
+
1308
+ ```json
1309
+ {
1310
+ "bounds": { "min": [-100, -100, -100], "max": [100, 100, 100] },
1311
+ "physics": { "level": "assets/level.glb" }
1312
+ }
1313
+ ```
1314
+
1315
+ The build bakes the default scene's node transforms and triangles into the
1316
+ physics package's indexed mesh format. It converts glTF Y-up to rules Z-up
1317
+ (`x, -z, y`), preserves reflected winding, and ships the baked mesh with the
1318
+ server rules and the browser movement bundle. Skins, animations and morph targets
1319
+ need a separate static collision GLB. The server never loads or parses a GLB.
1320
+
1321
+ The smallest moving capsule uses the same guarded movement on both sides:
1322
+
1323
+ ```ts
1324
+ // src/move.ts
1325
+ import { defineMove } from '@homie-rocks/studio/rules';
1326
+ export const move = defineMove({ runner(body, input, ctx) {
1327
+ ctx.substeps(body, 300, step => {
1328
+ if (step.first && input.jump && body.grounded)
1329
+ body.vel = { x: body.vel.x, y: body.vel.y, z: 10 };
1330
+ body.vel = { x: input.ax * 5, y: 0, z: body.vel.z - 30 * step.dt };
1331
+ ctx.physics.sweep(body, ctx.math.scale(body.vel, step.dt), .35, 1.35);
1332
+ });
1333
+ } });
1334
+
1335
+ // src/rules.ts
1336
+ import { defineRules, f } from '@homie-rocks/studio/rules';
1337
+ import { move } from './move';
1338
+ export default defineRules({
1339
+ contract: 2, space: { dims: 3 }, move,
1340
+ entities: { runner: {
1341
+ player: true,
1342
+ input: { ax: f.held(), jump: f.press() },
1343
+ body: { shape: 'capsule', radius: .35, height: 1.35, maxSpeed: 30 }
1344
+ } },
1345
+ room: { bots: { keep: 0 }, join() {
1346
+ return { kind: 'runner', at: { x: 0, y: 0, z: 8 } };
1347
+ } }
1348
+ });
1349
+ ```
1350
+
1351
+ Use `"room": {"host":"server","offline":false}` in `game.json`. Physics
1352
+ initialization is awaited once per isolate before any room can tick; calls inside
1353
+ a handler are synchronous. `sweep(body, delta, radius, height)` sweeps and slides
1354
+ an upright capsule whose `body.pos` is its centre and whose height includes its
1355
+ caps. It writes position, velocity and grounded state. `ctx.physics.cast`,
1356
+ `ray`, and `overlap` expose the package's convex shape cast, raycast and overlap
1357
+ queries; query shapes are boxes, spheres or capsules. Room/entity handlers have
1358
+ these operations at `world.physics` and can call `world.physics.step(seconds)`
1359
+ to advance the collision world's solver clock. Movement owns capsule integration;
1360
+ its queries do not create persistent physics bodies or advance another player's
1361
+ world. This bridge exposes the baked collision world, not rigid-body ownership or
1362
+ predicted dynamic-body interactions.
1363
+
1364
+ Every operation is charged before its engine work. On a 1,754-triangle level:
1365
+
1366
+ | Operation | Budget units |
1367
+ | --- | ---: |
1368
+ | Capsule/convex cast | 768 |
1369
+ | Ray | 640 |
1370
+ | Overlap | 640 |
1371
+ | Solver step | 8 + 2,400 per 1/300 second |
1372
+ | Capsule sweep and slide | 64 + 768 per cast, at most four casts |
1373
+
1374
+ Mesh-query and solver charges scale by `ceil(triangles / 1754)`, at least one.
1375
+ Substeps retain their usual pose and guarded callback charges. There is no new
1376
+ room budget, player cap or rate quota. The reproducible calibration is
1377
+ `scripts/calibrate-rules-physics.mjs`; its receipt compares physics on the real
1378
+ mesh with kernel operations on a box, since the kernel has no arbitrary-mesh
1379
+ or solver-step equivalent.
1380
+
1381
+ A room save includes the package's complete binary world snapshot as JSON bytes,
1382
+ including solver state and its time remainder. The capsule's position, velocity,
1383
+ grounded flag and declared motion are saved by rules as usual. Browser prediction
1384
+ creates the same baked world and replays pending guarded movement against it;
1385
+ substep drawing traces remain temporary. Physics worlds are disposed when their
1386
+ host or view closes. A full solver save can exceed browser-host checkpoint caps;
1387
+ the build measures this, and server-hosted rooms retain the full save.
@@ -6,6 +6,11 @@ metadata:
6
6
  providers: cloudflare github ollama
7
7
  ---
8
8
 
9
+ For a game with its own fixed integration rate, use the opt-in
10
+ `ctx.substeps(body, hz, step)` facility in `RULES.md` (Integrating at the game's
11
+ own rate). Its callbacks share the tick budget and its recorded poses drive
12
+ local drawing. Keep the game's input-transition policy explicit.
13
+
9
14
  Read the installed toolkit's `node_modules/@homie-rocks/studio/guides/game/{RULES,REWRITE}.md`
10
15
  before authoring: these match its runtime even when this plugin is older. Toolkit
11
16
  check/build report stale installed plugin copies and the update step. For solid slopes and terrain, use heightTiles with `base` and the source
@@ -451,3 +456,16 @@ nothing is cut off or runs off the side. Fix what you see; build again.
451
456
  A whole landing of the studio's own (`site/pages/<id>/index.html`) replaces the generated one: only when
452
457
  the person asks for a hand-made page, and start from the generated one's HTML so Play, the TV road and
453
458
  "Made with Homie" stay.
459
+
460
+ ## Physics
461
+
462
+ For movement against a static GLB's triangles, use the opt-in physics bridge in
463
+ [RULES.md](RULES.md#physics). Install `@homie-rocks/physics` 0.1.2, set
464
+ `map/main.json`'s `physics.level` to the game's GLB, and call
465
+ `ctx.physics.sweep(body, delta, radius, height)` inside the game's movement.
466
+ `ctx.substeps(body, 300, step => { ... })` shares its existing handler budget and
467
+ predicts the same baked world in the browser. The full example, cost table and
468
+ save contract are in that section. Prefer kernel collision for its primitive
469
+ maps; use physics for arbitrary static triangle levels. Initialization is awaited
470
+ before the first room tick, calls in handlers are synchronous, and the complete
471
+ physics snapshot is saved with the room. No opt-in means no physics load or cost.
package/lib/doctor.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { providerKey } from './provider-key.mjs';
1
2
  /**
2
3
  * `homie-studio setup status` (also `homie-studio doctor`): what this computer and this person's accounts have for
3
4
  * a studio, as one checklist, before anything is made. For each thing: green, missing, or "do this now", what it
@@ -231,16 +232,17 @@ export async function setupStatus({
231
232
  const s = await exec(cliBin, ['auth', 'status', '--format', 'json'], { timeout: 10_000 });
232
233
  try { signedIn = Boolean(JSON.parse(s.stdout)?.schemes?.some((x) => x.logged_in)); } catch { signedIn = false; }
233
234
  }
234
- return { cli: v.code === 0, signedIn, key: Boolean(env.ELEVENLABS_API_KEY), projects: root ? projectsElevenLabs(root) : false };
235
+ const credential = providerKey('elevenlabs', env);
236
+ return { cli: v.code === 0, signedIn, key: Boolean(credential.key), source: credential.source, projects: root ? projectsElevenLabs(root) : false };
235
237
  })(),
236
238
  (async () => {
237
- const key = String(env.FAL_KEY ?? '').trim();
239
+ const { key, source } = providerKey('fal', env);
238
240
  if (!key) return { key: false };
239
241
  const base = String(env.FAL_API_URL || 'https://api.fal.ai').replace(/\/+$/, '');
240
242
  try {
241
243
  const res = await fetchFn(`${base}/v1/models/pricing?endpoint_id=fal-ai/flux/dev`, { headers: { Authorization: `Key ${key}` }, signal: AbortSignal.timeout(6000) });
242
- return { key: true, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
243
- } catch { return { key: true, valid: null }; }
244
+ return { key: true, source, valid: res.ok ? true : res.status === 401 || res.status === 403 ? false : null };
245
+ } catch { return { key: true, source, valid: null }; }
244
246
  })(),
245
247
  ]);
246
248
 
@@ -386,7 +388,7 @@ export async function setupStatus({
386
388
  // ElevenLabs.
387
389
  rows.push({
388
390
  id: 'elevenlabs', label: 'ElevenLabs', need: 'optional', state: eleven.signedIn || eleven.key || eleven.projects ? 'ok' : 'optional',
389
- detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? 'ELEVENLABS_API_KEY is set' : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
391
+ detail: eleven.signedIn ? 'the elevenlabs CLI is signed in' : eleven.key ? `key from ${eleven.source}` : eleven.projects ? 'through Stripe Projects (its key, in the studio\'s git-ignored .env; never shown)' : eleven.cli ? 'the elevenlabs CLI is here, not signed in' : 'not connected',
390
392
  unlocks: 'songs and game scores (the music skill), billed to your own ElevenLabs plan; every render is quoted first',
391
393
  fix: eleven.signedIn || eleven.key || eleven.projects ? null : eleven.cli
392
394
  ? { who: 'ai', run: 'elevenlabs auth login', say: 'ElevenLabs opens in your browser; sign in once. No key is pasted anywhere.' }
@@ -396,7 +398,7 @@ export async function setupStatus({
396
398
  // fal.
397
399
  rows.push({
398
400
  id: 'fal', label: 'fal', need: 'optional', state: fal.key ? (fal.valid === false ? 'act' : 'ok') : 'optional',
399
- detail: !fal.key ? 'no FAL_KEY in this environment' : fal.valid === true ? 'FAL_KEY is set and fal accepts it' : fal.valid === false ? 'fal refused the FAL_KEY that is set (wrong or revoked)' : 'FAL_KEY is set (fal did not answer just now)',
401
+ detail: !fal.key ? 'no fal key in the environment or Homie key files' : `key from ${fal.source}; ${fal.valid === true ? 'fal accepts it' : fal.valid === false ? 'fal refused it (wrong or revoked)' : 'fal did not answer just now'}`,
400
402
  unlocks: 'painted art and backdrops (the art skill) and generated video clips (the video skill), on your own fal account under a budget; covers from a real frame and captured trailers are free',
401
403
  fix: fal.key && fal.valid !== false ? null : { who: 'ai', say: 'Use local art and captured footage. Paid fal scripts need an already connected account; never ask the person to find or set a key. Offer the provider browser connection only when generated media is requested.', open: 'https://fal.ai/dashboard' },
402
404
  });
package/lib/mcp-tools.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { providerKey } from './provider-key.mjs';
1
2
  /**
2
3
  * THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
3
4
  * desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
@@ -1497,7 +1498,7 @@ export function toolDefs(ctx, avail = {}) {
1497
1498
 
1498
1499
  /** What the media tools need, cheaply (no network): ffmpeg on the PATH, ElevenLabs' CLI or key. */
1499
1500
  export function availability(env = process.env) {
1500
- return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(env.ELEVENLABS_API_KEY || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
1501
+ return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(providerKey('elevenlabs', env).key || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
1501
1502
  }
1502
1503
 
1503
1504
  /** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
@@ -0,0 +1,40 @@
1
+ import { NodeIO } from '@gltf-transform/core';
2
+ import { Matrix4, Vector3 } from 'three';
3
+
4
+ /** Build-time only: bake the default GLB scene, including every node transform.
5
+ * Rules use Z-up; glTF is Y-up. (x,y,z) -> (x,-z,y) preserves handedness.
6
+ * Animated, skinned and morphing geometry needs an explicitly authored static collider.
7
+ */
8
+ export async function bakePhysicsMesh(path) {
9
+ const document = await new NodeIO().read(path);
10
+ const root = document.getRoot();
11
+ const scene = root.getDefaultScene() ?? root.listScenes()[0];
12
+ if (!scene || root.listAnimations().length) throw new Error('physics mesh: needs a static default scene');
13
+ const vertices = [], indices = [];
14
+ scene.traverse(node => {
15
+ if (node.getSkin()) throw new Error('physics mesh: skinned nodes need a static collider');
16
+ const matrix = new Matrix4().fromArray(node.getWorldMatrix());
17
+ const mirrored = matrix.determinant() < 0;
18
+ for (const primitive of node.getMesh()?.listPrimitives() ?? []) {
19
+ if (primitive.getMode() !== 4 || primitive.listTargets().length) throw new Error('physics mesh: only static triangles are supported');
20
+ const positions = primitive.getAttribute('POSITION'), index = primitive.getIndices();
21
+ if (!positions || positions.getElementSize() !== 3) throw new Error('physics mesh: missing xyz positions');
22
+ const base = vertices.length / 3;
23
+ for (let i = 0; i < positions.getCount(); i++) {
24
+ const p = new Vector3().fromArray(positions.getElement(i, [])).applyMatrix4(matrix);
25
+ if (![p.x,p.y,p.z].every(Number.isFinite)) throw new Error('physics mesh: nonfinite vertex');
26
+ vertices.push(Math.fround(p.x), Math.fround(-p.z), Math.fround(p.y));
27
+ }
28
+ const count = index?.getCount() ?? positions.getCount();
29
+ if (count % 3) throw new Error('physics mesh: incomplete triangle');
30
+ for (let i = 0; i < count; i += 3) {
31
+ const triangle = [0,1,2].map(j => index ? index.getScalar(i+j) : i+j);
32
+ if (!triangle.every(n => Number.isSafeInteger(n) && n >= 0 && n < positions.getCount())) throw new Error('physics mesh: index out of range');
33
+ if (mirrored) [triangle[1],triangle[2]] = [triangle[2],triangle[1]];
34
+ indices.push(...triangle.map(n => base+n));
35
+ }
36
+ }
37
+ });
38
+ if (!indices.length) throw new Error('physics mesh: no triangles');
39
+ return { vertices, indices };
40
+ }
@@ -0,0 +1,21 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ /** Read only; callers must expose source, never key. No environment mutation. */
6
+ export function providerKey(provider, env = process.env) {
7
+ const variable = { fal: 'FAL_KEY', elevenlabs: 'ELEVENLABS_API_KEY' }[provider];
8
+ if (!variable) throw new TypeError('unknown media provider');
9
+ const value = String(env[variable] ?? '').trim();
10
+ if (value) return { key: value, source: variable };
11
+ // An explicitly supplied environment is also an isolated home in tests/tools.
12
+ const home = env.HOME || env.USERPROFILE || (env === process.env ? homedir() : null);
13
+ if (home) for (const relative of [`keys/${provider}-key`, `workspace/${provider}-key`]) {
14
+ const path = join(home, '.homie', relative);
15
+ try {
16
+ const key = readFileSync(path, 'utf8').trim();
17
+ if (key && !/[\r\n]/.test(key)) return { key, source: `~/.homie/${relative}` };
18
+ } catch { /* Missing or unreadable files are not credentials. */ }
19
+ }
20
+ return { key: null, source: null };
21
+ }
@@ -23,8 +23,8 @@ import { seatsOf } from '../worker/seats.mjs';
23
23
  */
24
24
  import { createHash } from 'node:crypto';
25
25
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
26
- import { join } from 'node:path';
27
- import { pathToFileURL } from 'node:url';
26
+ import { join, resolve, relative, isAbsolute } from 'node:path';
27
+ import { pathToFileURL, fileURLToPath } from 'node:url';
28
28
  import { GUARD_MODULE, RULES_MODULE, guardRules, guardedPlugin, problemLine } from './rules-guard.mjs';
29
29
  import { capOf, RULES_FRAMES, rulesRates } from '../worker/limits.mjs';
30
30
  import { PACKAGE_ROOT, isRulesGame, rulesIndex } from './studio.mjs';
@@ -66,15 +66,15 @@ function readMap(g) {
66
66
  * `W` (guard.ts, the wall), `P` (pack.ts) and `M` (math.ts). They are one instance together, apart from any other
67
67
  * copy of the runtime in this process, so the guard's budget counter is theirs alone.
68
68
  */
69
- export async function loadRules(esbuild, root, id, code, { bundleOnly = false } = {}) {
69
+ export async function loadRules(esbuild, root, id, code, { bundleOnly = false, physics = false } = {}) {
70
70
  const dir = join(root, '.studio', 'rules');
71
71
  mkdirSync(dir, { recursive: true });
72
72
  const gen = join(dir, `${id}-${process.pid}-${++loads}.linked.mjs`);
73
73
  writeFileSync(gen, code);
74
74
  const lib = (name) => JSON.stringify(join(PACKAGE_ROOT, 'rules', name));
75
- const entry = `import def from ${JSON.stringify(gen)};\nimport * as R from ${lib('rules.ts')};\nimport * as H from ${lib('host.ts')};\nimport * as C from ${lib('core.ts')};\nimport * as W from ${lib('guard.ts')};\nimport * as P from ${lib('pack.ts')};\nimport * as M from ${lib('math.ts')};\nexport { def, R, H, C, W, P, M };\n`;
75
+ const entry = `import def from ${JSON.stringify(gen)};\nimport * as R from ${lib('rules.ts')};\nimport * as H from ${lib('host.ts')};\nimport * as C from ${lib('core.ts')};\nimport * as W from ${lib('guard.ts')};\nimport * as P from ${lib('pack.ts')};\nimport * as M from ${lib('math.ts')};\nexport { def, R, H, C, W, P, M };\n${physics ? `export { initializePhysics } from ${lib('physics-engine.ts')};` : ''}`;
76
76
  try {
77
- const res = await esbuild.build({ stdin: { contents: entry, resolveDir: root, loader: 'js' }, bundle: true, format: bundleOnly ? 'cjs' : 'esm', target: 'es2022', platform: 'neutral', write: false, logLevel: 'silent', plugins: [rulesModulesPlugin()] });
77
+ const res = await esbuild.build({ stdin: { contents: entry, resolveDir: root, loader: 'js' }, bundle: true, format: bundleOnly ? 'cjs' : 'esm', target: 'es2022', platform: 'neutral', ...(physics ? {mainFields: ['module','main']} : {}), write: false, logLevel: 'silent', plugins: [rulesModulesPlugin()] });
78
78
  const text = res.outputFiles[0].text;
79
79
  if (bundleOnly) { rmSync(gen, { force: true }); return text; }
80
80
  const out = join(dir, `${id}.check-${createHash('sha256').update(text).digest('hex').slice(0, 12)}-${process.pid}-${(loads += 1)}.mjs`);
@@ -146,8 +146,15 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
146
146
  if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
147
147
  const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
148
148
  const map = readMap(g);
149
+ if (map.physics !== undefined) {
150
+ const { bakePhysicsMesh } = await import('./physics-mesh.mjs');
151
+ if (!map.physics || typeof map.physics.level !== 'string') throw new Error('map.physics.level must name the game’s static GLB');
152
+ const path = resolve(g.dir, map.physics.level), rel = relative(g.dir, path);
153
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('physics level must be inside the game');
154
+ map.physics = await bakePhysicsMesh(path);
155
+ }
149
156
  const seats = seatsOf(g);
150
- const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true });
157
+ const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true, physics: Boolean(map.physics) });
151
158
  const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
152
159
  // The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
153
160
  await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
@@ -158,7 +165,8 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
158
165
  const trial = RULES_CAPACITY_TRIAL;
159
166
  log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
160
167
  if (settings.tickHz > trial.tickHz) log(`info: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
161
- const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
168
+ const { def, R, H, initializePhysics } = await loadRules(esbuild, root, g.id, guarded.code, {physics: Boolean(map.physics)});
169
+ if (initializePhysics) await initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
162
170
  const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
163
171
  const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
164
172
  const snapshotCap = capOf('snap', seats); const checkpointCap = capOf('ckpt', seats);
@@ -207,8 +215,8 @@ export function viewPlugin(g, rules, entry, { lab = false } = {}) {
207
215
  if (a.path === 'homie:rules') return { contents: rules.code, resolveDir: g.dir, loader: 'js' };
208
216
  if (a.path === 'homie:host') return { contents: `import def from 'homie:rules';\nimport { browserHost } from ${JSON.stringify(join(PACKAGE_ROOT, 'rules', 'browser.ts'))};\nexport const makeHost = browserHost(def, ${JSON.stringify({ id: g.id, build: rules.build, tune: rules.tune, map: rules.map, settings: rules.settings, seats: rules.seats, vocab: game.vocab })});\n`, resolveDir: g.dir, loader: 'js' };
209
217
  return (a.path === 'homie:view'
210
- ? { contents: `import 'homie:game';\nimport ${JSON.stringify(entry)};\n`, resolveDir: g.dir, loader: 'js' }
211
- : { contents: `${existsSync(move) ? `import { move } from ${JSON.stringify(move)};` : 'const move = {};'}\nimport { setGame${game.vocab ? ', setAgentFactory' : ''} } from '${RULES_MODULE}/view';\n${game.vocab ? `import { useAgents } from ${JSON.stringify(join(PACKAGE_ROOT, 'agents/agents.ts'))}; setAgentFactory(useAgents);` : ''}\n${factory}\nsetGame(${JSON.stringify(game)}, move, loadHost);\n`, resolveDir: g.dir, loader: 'js' });
218
+ ? { contents: `import 'homie:game';\n${rules.map.physics ? `await import(${JSON.stringify(entry)});` : `import ${JSON.stringify(entry)};`}\n`, resolveDir: g.dir, loader: 'js' }
219
+ : { contents: `${existsSync(move) ? `import { move } from ${JSON.stringify(move)};` : 'const move = {};'}\nimport { setGame${game.vocab ? ', setAgentFactory' : ''} } from '${RULES_MODULE}/view';\n${game.vocab ? `import { useAgents } from ${JSON.stringify(join(PACKAGE_ROOT, 'agents/agents.ts'))}; setAgentFactory(useAgents);` : ''}\n${factory}\n${rules.map.physics ? `import {initializePhysics} from ${JSON.stringify(join(PACKAGE_ROOT, 'rules', 'physics-engine.ts'))}; import bytes from ${JSON.stringify(fileURLToPath(new URL('./dist/vendor/bytes.js', import.meta.resolve('@homie-rocks/physics/package.json'))))}; await initializePhysics(Uint8Array.from(atob(bytes), c => c.charCodeAt(0)));` : ''}\nsetGame(${JSON.stringify(game)}, move, loadHost);\n`, resolveDir: g.dir, loader: 'js' });
212
220
  });
213
221
  rulesModulesPlugin().setup(b);
214
222
  guarded.setup(b);
@@ -228,7 +236,7 @@ export function writeRules(root, built, ids) {
228
236
  mkdirSync(dir, { recursive: true });
229
237
  for (const r of built.filter((r) => r.settings.host === 'server')) {
230
238
  writeFileSync(join(dir, `${r.id}.mjs`), `${HEADER}// The rules of games/${r.id}, checked and guarded (build ${r.build}).\n${r.code}`);
231
- writeFileSync(join(dir, `${r.id}.data.mjs`), `${HEADER}export default ${JSON.stringify({ tune: r.tune, map: r.map, settings: r.settings, seats: r.seats, build: r.build, stateHash: r.stateHash }, null, 2)};\n`);
239
+ writeFileSync(join(dir, `${r.id}.data.mjs`), `${HEADER}${r.map.physics ? `import {initializePhysics} from '@homie-rocks/studio/rules/physics-engine';\nimport wasm from '@homie-rocks/physics/vendor/rapier.wasm';\n` : ''}export default ${r.map.physics ? '{initialize: () => initializePhysics(wasm), ...' : ''}${JSON.stringify({ tune: r.tune, map: r.map, settings: r.settings, seats: r.seats, build: r.build, stateHash: r.stateHash }, null, 2)}${r.map.physics ? '}' : ''};\n`);
232
240
  }
233
241
  const have = ids.filter((id) => existsSync(join(dir, `${id}.mjs`)) && existsSync(join(dir, `${id}.data.mjs`)));
234
242
  for (const name of readdirSync(dir)) {
@@ -20,6 +20,7 @@ import { boundPosition } from '../rules/topology.mjs';
20
20
  * units used by the room and by the browsers' `move`, bytes of save rebuilt), never by a clock, so the same source gives
21
21
  * the same verdict and the same lines on every computer.
22
22
  */
23
+ import { readFileSync } from 'node:fs';
23
24
  import { parentPort, workerData } from 'node:worker_threads';
24
25
  import { compileFunction } from 'node:vm';
25
26
  import { checkDecide } from '../worker/brain.mjs';
@@ -356,6 +357,7 @@ function play(c, companions, allow) {
356
357
  const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
357
358
  result.pos = L.P.vec3(boundPosition(result.pos,geometry??c.map,r,height,c.dims),c.dims);
358
359
  delete result.used;
360
+ delete result.steps; // Drawing traces are not authoritative body fields.
359
361
  if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
360
362
  }
361
363
  if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
@@ -390,6 +392,7 @@ try {
390
392
  let at = null; try { const g = linked.exports.W.G; if (g.line) at = `${g.file}:${g.line}`; } catch { /* the guard itself did not load */ }
391
393
  throw new Error(`${at ?? here()}: ${diagnostic(String(error?.message ?? error))}`);
392
394
  }
395
+ if (L.initializePhysics) await L.initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
393
396
  const { c, problems } = compile();
394
397
  const result = { vocab: data.vocab, schema: L.R.schemaOf(c), publicTune: c.publicTune, settings: c.settings, problems, rounds: c.rounds, stateHash: stateHash(c),
395
398
  declarations: { events: c.events, asks: Object.fromEntries(Object.entries(c.asks).map(([n, a]) => [n, { state: a.stateFields, questions: a.questions }])), view: L.def.shapes?.view ?? {} } };
@@ -67,5 +67,5 @@ export function runRulesWorker(bundle, data, { declarationsOnly = false, allowan
67
67
  export async function checkRules(esbuild, root, id, code, data, options = {}) {
68
68
  // Imported here, not at the top: the worker imports this file for `stateHash`, and has no use for the build's tools.
69
69
  const { loadRules } = await import('./rules-build.mjs');
70
- return runRulesWorker(await loadRules(esbuild, root, id, code, { bundleOnly: true }), { id, ...data }, options);
70
+ return runRulesWorker(await loadRules(esbuild, root, id, code, { bundleOnly: true, physics: Boolean(data.map?.physics) }), { id, ...data }, options);
71
71
  }
@@ -69,9 +69,10 @@ export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
69
69
  export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots', 'replace', 'tile', 'distance', 'flow'];
70
70
  export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
71
71
  const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
72
- export const MOVE_METHODS = ['support', 'overlaps'];
73
- const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
74
- const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
72
+ export const MOVE_METHODS = ['substeps', 'support', 'overlaps'];
73
+ export const PHYSICS_METHODS = ['step', 'cast', 'overlap'];
74
+ const NEW_METHODS = new Set(['step','cast','overlap','substeps','route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
75
+ const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...PHYSICS_METHODS, ...MATH_METHODS]);
75
76
  const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
76
77
  /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
77
78
  const CONVERTING = new Set(['-', '*', '/', '%', '&', '|', '^', '<<', '>>', '>>>', '<', '>', '<=', '>=']);
@@ -2943,6 +2943,48 @@
2943
2943
  }
2944
2944
  }
2945
2945
  }
2946
+ },
2947
+ "0.48.0": {
2948
+ "files": {
2949
+ "CLAUDE.md": "ddf2b2a0376202f6",
2950
+ "README.md": "684b3cca8001673c",
2951
+ "HANDOFF.md": "29337e2ace95b2e7",
2952
+ "games/README.md": "5ad32fe80c61f117",
2953
+ "music/README.md": "a3dc042dadd39df8",
2954
+ "videos/README.md": "16babc3437d5545b",
2955
+ "posts/README.md": "37841aae476bd540",
2956
+ "site/README.md": "732894273a32dc3f",
2957
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2958
+ },
2959
+ "sections": {
2960
+ "AGENTS.md": {
2961
+ "(intro)": "394cbbe934e80722",
2962
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2963
+ "## Layout": "f3a280b8c96cd068",
2964
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2965
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
2966
+ "## The site": "0cb8dd9a6f8a0096",
2967
+ "## Making games": "bb715272fd1a615b",
2968
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2969
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2970
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2971
+ "## Servers and AI seats": "1ccfd549d2369a60",
2972
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2973
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2974
+ "## Rules": "27881d11d3ff3803",
2975
+ "## Telling Homie": "80b46fdd9e578db8",
2976
+ "## Beta": "9d02638207a26e73"
2977
+ }
2978
+ },
2979
+ "named": {
2980
+ "Homie Arcade": {
2981
+ "sections": {
2982
+ "AGENTS.md": {
2983
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
2984
+ }
2985
+ }
2986
+ }
2987
+ }
2946
2988
  }
2947
2989
  }
2948
2990
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -92,7 +92,8 @@
92
92
  "./functions": {
93
93
  "types": "./functions/functions.ts",
94
94
  "default": "./functions/functions.ts"
95
- }
95
+ },
96
+ "./rules/physics-engine": "./rules/physics-engine.ts"
96
97
  },
97
98
  "files": [
98
99
  "docs/",
@@ -165,5 +166,13 @@
165
166
  "scripts": {
166
167
  "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
167
168
  "prepack": "node lib/build-identity.mjs --check && node ../../scripts/authoring-guides.mjs --check"
169
+ },
170
+ "peerDependencies": {
171
+ "@homie-rocks/physics": "0.1.2"
172
+ },
173
+ "peerDependenciesMeta": {
174
+ "@homie-rocks/physics": {
175
+ "optional": true
176
+ }
168
177
  }
169
178
  }
package/rules/core.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { physicsWorld } from './physics.ts';
2
+ import { substeps } from './substeps.ts';
1
3
  import { prepareMapReplacement, type MapRevision } from './map-state.ts';
2
4
  import { tileApi } from './tiles.ts';
3
5
  import { boundPosition, periodicDelta, relativePoint, blendPosition, wrapPoint } from './topology.mjs';
@@ -143,6 +145,7 @@ export interface SavedCore {
143
145
  v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
144
146
  round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
145
147
  shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
148
+ physics?: number[];
146
149
  collision?: [number,number];
147
150
  maps?: {current:MapRevision|null;pending:MapRevision|null};
148
151
  history?: [number, [string, number, number, number, number][]][];
@@ -178,6 +181,7 @@ export interface Core {
178
181
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
179
182
  drain(): CoreOut[];
180
183
  save(): SavedCore;
184
+ dispose(): void;
181
185
  /** The `world` object rules are handed (a test walks it to prove nothing else is reachable). */
182
186
  readonly world: object;
183
187
  /**
@@ -195,6 +199,8 @@ export interface Core {
195
199
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
196
200
  */
197
201
  export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
202
+ if (opts.restore && (c.map.physics !== undefined) !== (opts.restore.physics !== undefined)) throw new Error('physics: save and game differ');
203
+ const physics = physicsWorld(c.map.physics, opts.restore?.physics);
198
204
  let currentMap=c.map;
199
205
  let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
200
206
  let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
@@ -553,6 +559,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
553
559
  tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
554
560
  // What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
555
561
  unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
562
+ ...(physics ? {physics: () => physics.api} : {}),
556
563
  stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
557
564
  };
558
565
  for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
@@ -824,7 +831,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
824
831
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
825
832
  const moveCtx = brand({} as Record<string, unknown>);
826
833
  for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap, world: () => moveWorld })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
827
- Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
834
+ Object.assign(moveCtx, { substeps: substeps(c.settings.tickHz), ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
835
+ if (physics) Object.assign(moveCtx, {physics: physics.movement});
828
836
  Object.freeze(moveCtx);
829
837
  const joinCtx = brand({} as Record<string, unknown>);
830
838
  for (const [name, get] of Object.entries({ map: () => mapApi, shared: sharedView, tune: () => c.tune, math: () => math, round: () => roundApi })) Object.defineProperty(joinCtx, name, { get, enumerable: true });
@@ -1281,6 +1289,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1281
1289
  }
1282
1290
  function save(): SavedCore {
1283
1291
  return {
1292
+ ...(physics ? {physics: physics.save()} : {}),
1284
1293
  v: SAVE_REVISION, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
1285
1294
  ...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1286
1295
  ...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
@@ -1307,7 +1316,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1307
1316
  for(const [name,value] of Object.entries(r.maps)){if(value===null)continue;check(Array.isArray(value)&&value.length===2&&uint(value[0])&&(name==='pending'?value[0]===r.tick+1:value[0]<=r.tick));prepareMapReplacement(value[1],c.map);}
1308
1317
  }
1309
1318
  if(r.collision!==undefined)check(Array.isArray(r.collision)&&r.collision.length===2&&uint(r.collision[0])&&uint(r.collision[1])&&r.collision[0]<=r.tick&&r.collision[1]===r.collision[0]+1);
1310
- check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision', 'maps'].includes(k)));
1319
+ check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision', 'maps', 'physics'].includes(k)));
1311
1320
  const askNames = new Set();
1312
1321
  check(Array.isArray(r.asks));
1313
1322
  for (const a of r.asks) {
@@ -1429,6 +1438,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1429
1438
  }
1430
1439
  }
1431
1440
  const r = opts.restore;
1441
+ try {
1432
1442
  if (r) {
1433
1443
  if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
1434
1444
  validateSave(r);
@@ -1466,6 +1476,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1466
1476
  ops = r.ops as typeof ops;
1467
1477
  if(hasColliders||mapRevision||pendingMap)collisionState();
1468
1478
  } else startMatch();
1479
+ } catch (error) { physics?.dispose(); throw error; }
1469
1480
 
1470
1481
  function decisionResult(value: unknown): Record<string, unknown> {
1471
1482
  const v = plainData(value, { n: ANSWER_MAX });
@@ -1560,6 +1571,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1560
1571
  bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
1561
1572
  drain: () => { const o = out; out = []; return o; },
1562
1573
  save,
1574
+ dispose: () => physics?.dispose(),
1563
1575
  world,
1564
1576
  stats,
1565
1577
  };
package/rules/host.ts CHANGED
@@ -932,6 +932,7 @@ export function createHost(o: HostOptions): Host {
932
932
  }
933
933
  function stop(): void {
934
934
  running = false; paused = false; ended = true;
935
+ core.dispose();
935
936
  disarm();
936
937
  agents?.stop();
937
938
  for (let i = recent.length - 1; i >= 0; i -= 1) if (recent[i].who === self) recent.splice(i, 1);
package/rules/math.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { physicsWorld } from './physics.ts';
2
+ import { substeps } from './substeps.ts';
1
3
  import { wrapPoint } from './topology.mjs';
2
4
  import { tileApi } from './tiles.ts';
3
5
  import { nearbyMap } from './map-index.ts';
@@ -299,15 +301,22 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
299
301
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
300
302
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
301
303
  */
304
+ const physicsContexts = new WeakMap<object, () => void>();
305
+ export function disposeMoveContext(context: object): void { physicsContexts.get(context)?.(); physicsContexts.delete(context); }
302
306
  export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes; self?: () => string | undefined }): unknown {
307
+ const physics = physicsWorld((o.map as import('./rules.ts').GameMap).physics);
303
308
  const none = Object.freeze([]);
304
309
  const currentMap = () => (o.geometry?.()?.staticMap ?? o.geometry?.() ?? o.map) as import('./rules.ts').GameMap;
305
310
  const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
306
- return brand(Object.freeze({
311
+ const context = brand(Object.freeze({
312
+ ...(physics ? {physics: physics.movement} : {}),
313
+ substeps: substeps(o.tickHz),
307
314
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
308
315
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
309
316
  ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
310
317
  world: brand(Object.freeze(queries)),
311
318
  map: brand(Object.freeze({ ...tileApi(currentMap), name: o.name, spot: (name: string) => (own(currentMap().spots ?? o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(currentMap().spots ?? o.spots, name) ?? none, ...queries })),
312
319
  }));
320
+ if (physics) physicsContexts.set(context, physics.dispose);
321
+ return context;
313
322
  }
package/rules/pack.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { takeSubsteps, type StepPose } from './substeps.ts';
1
2
  /*
2
3
  * pack.ts — declared state in and out: holding a value to its declared type, and packing it for the wire and the save.
3
4
  * =============================================================================
@@ -262,7 +263,7 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
262
263
  * left rounded to 32-bit floats and held to the declared shapes as the server does it, so both hold the same numbers.
263
264
  * `used` is the units it took. What it throws goes to `failed` (the budget's own stop too) and the body is still read.
264
265
  */
265
- export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
266
+ export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number; steps?: readonly StepPose[] } {
266
267
  G.queryEpoch++; G.left = quota;
267
268
  const bag = coerceFields(motion, body.motion, dims);
268
269
  const target: Record<string, unknown> = {};
@@ -296,7 +297,8 @@ export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => v
296
297
  const used = quota - (G.left > 0 ? G.left : 0);
297
298
  G.left = Infinity;
298
299
  naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims); naming();
299
- return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used };
300
+ const steps = takeSubsteps(body);
301
+ return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used, ...(steps ? {steps} : {}) };
300
302
  }
301
303
 
302
304
  /** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
@@ -0,0 +1,73 @@
1
+ /** Loaded only by games declaring a baked physics level. */
2
+ import { createWorld } from '@homie-rocks/physics/World.js';
3
+ import { initPhysics } from '@homie-rocks/physics/Worker.js';
4
+ import { brand, charge } from './guard.ts';
5
+ import { registerPhysics, type PhysicsMovement, type RulesPhysics } from './physics.ts';
6
+
7
+ // CPU calibration is recorded in docs/physics-048-calibration.md. Charges precede work.
8
+ export const PHYSICS_UNITS = Object.freeze({ cast: 768, ray: 640, overlap: 640, step: 2400 });
9
+ export async function initializePhysics(source: WebAssembly.Module | BufferSource): Promise<void> {
10
+ await initPhysics(source);
11
+ registerPhysics((mesh, saved) => {
12
+ const world = createWorld({ up: 'z', fixedDt: 1 / 300, maxSubsteps: 128, bounds: null });
13
+ try {
14
+ if (saved) world.restore(Uint8Array.from(saved));
15
+ else world.createBody({ type: 'static', colliders: [{ shape: { kind: 'mesh', vertices: Float32Array.from(mesh.vertices), indices: Uint32Array.from(mesh.indices) } }] });
16
+ const count = Math.max(1, Math.ceil(mesh.indices.length / (1754 * 3)));
17
+ const convex = (shape: {kind:string}) => { if (!['box','sphere','capsule'].includes(shape.kind)) throw new Error('rules physics queries use a box, sphere or capsule'); };
18
+ const movement: PhysicsMovement = {
19
+ cast(...args) { charge(PHYSICS_UNITS.cast * count); convex(args[0]); return world.castShape(...args); },
20
+ ray(...args) { charge(PHYSICS_UNITS.ray * count); return world.raycast(...args); },
21
+ overlap(...args) { charge(PHYSICS_UNITS.overlap * count); convex(args[0]); return world.overlaps(...args); },
22
+ sweep(body, delta, radius, height) {
23
+ charge(64);
24
+ let left = { ...delta }, pos = { ...body.pos }, vel = { ...body.vel };
25
+ let grounded = false;
26
+ for (let n = 0; n < 4; n++) {
27
+ const length = Math.hypot(left.x, left.y, left.z);
28
+ if (length < 1e-9) break;
29
+ charge(PHYSICS_UNITS.cast * count);
30
+ const hit = world.castCapsule(pos, left, radius, height);
31
+ const fraction = hit ? Math.max(0, hit.fraction) : 1;
32
+ pos = { x: pos.x + left.x * fraction, y: pos.y + left.y * fraction, z: pos.z + left.z * fraction };
33
+ if (!hit) break;
34
+ if (hit.normal.z > .65 && delta.z <= 0) grounded = true;
35
+ const normal = hit.normal;
36
+ const dot = left.x * normal.x + left.y * normal.y + left.z * normal.z;
37
+ const speed = vel.x * normal.x + vel.y * normal.y + vel.z * normal.z;
38
+ if (normal.z > .65 && delta.z <= 0) {
39
+ // Lift the requested horizontal travel along a walkable slope.
40
+ // Projecting gravity onto its normal would make an idle capsule drift.
41
+ left = { x: left.x * (1-fraction), y: left.y * (1-fraction), z: 0 };
42
+ left.z = -(left.x * normal.x + left.y * normal.y) / normal.z;
43
+ vel = { x: vel.x, y: vel.y, z: Math.max(0, vel.z) };
44
+ } else {
45
+ left = { x: (left.x-normal.x*Math.min(0,dot))*(1-fraction), y: (left.y-normal.y*Math.min(0,dot))*(1-fraction), z: (left.z-normal.z*Math.min(0,dot))*(1-fraction) };
46
+ vel = { x:vel.x-normal.x*Math.min(0,speed), y:vel.y-normal.y*Math.min(0,speed), z:vel.z-normal.z*Math.min(0,speed) };
47
+ }
48
+ }
49
+ body.pos = pos; body.vel = vel; body.grounded = grounded;
50
+ },
51
+ };
52
+ const api: RulesPhysics = {
53
+ ...movement,
54
+ step(dt) {
55
+ charge(8);
56
+ if (!Number.isFinite(dt) || dt < 0) throw new Error('physics: dt must be finite and nonnegative');
57
+ charge(PHYSICS_UNITS.step * count * Math.max(1, Math.ceil(dt * 300)));
58
+ while (dt > .1) { world.step(.1); dt -= .1; }
59
+ world.step(dt);
60
+ },
61
+ };
62
+ let disposed = false, finalSave: number[] | undefined;
63
+ return { api: brand(Object.freeze(api)), movement: brand(Object.freeze(movement)),
64
+ save: () => finalSave ? [...finalSave] : Array.from(world.snapshot()),
65
+ dispose: () => {
66
+ if (disposed) return;
67
+ try { finalSave = Array.from(world.snapshot()); } catch { /* A failed engine has no final checkpoint. */ }
68
+ world.dispose(); disposed = true;
69
+ },
70
+ };
71
+ } catch (error) { world.dispose(); throw error; }
72
+ });
73
+ }
@@ -0,0 +1,26 @@
1
+ /** Opt-in boundary. Importing the rules kernel never imports or initializes WASM. */
2
+ import type { Vec3 } from './rules.ts';
3
+ export interface PhysicsMesh { vertices: readonly number[]; indices: readonly number[] }
4
+ // Structural query types keep the ordinary rules types independent of the optional package.
5
+ export type PhysicsShape = {kind: 'box'; halfExtents: Vec3} | {kind: 'sphere'; radius: number} | {kind: 'capsule'; radius: number; halfHeight: number};
6
+ export interface PhysicsRotation {x: number; y: number; z: number; w: number}
7
+ export interface PhysicsFilter {layers?: {membership: number; filter: number}; excludeBody?: number; excludeCollider?: number; sensors?: boolean}
8
+ export interface PhysicsHit {collider: number; body: number; point: Vec3; normal: Vec3}
9
+ export interface PhysicsMovement {
10
+ sweep(body: {pos: Vec3; vel: Vec3; grounded: boolean}, delta: Vec3, radius: number, height: number): void;
11
+ ray(origin: Vec3, direction: Vec3, distance: number, filter?: PhysicsFilter): (PhysicsHit & {distance: number}) | null;
12
+ overlap(shape: PhysicsShape, position: Vec3, orientation?: PhysicsRotation, filter?: PhysicsFilter): number[];
13
+ cast(shape: PhysicsShape, position: Vec3, delta: Vec3, orientation?: PhysicsRotation, filter?: PhysicsFilter): (PhysicsHit & {fraction: number}) | null;
14
+ }
15
+ export interface RulesPhysics extends PhysicsMovement {
16
+ step(seconds: number): void;
17
+ }
18
+ export interface PhysicsBridge { api: RulesPhysics; movement: PhysicsMovement; save(): number[]; dispose(): void }
19
+ let factory: ((mesh: PhysicsMesh, saved?: number[]) => PhysicsBridge) | undefined;
20
+ export function registerPhysics(value: NonNullable<typeof factory>): void { factory = value; }
21
+ export function physicsWorld(mesh?: PhysicsMesh, saved?: number[]): PhysicsBridge | undefined {
22
+ if (!mesh) { if (saved !== undefined) throw new Error('physics save requires a physics level'); return; }
23
+ if (saved !== undefined && (!Array.isArray(saved) || saved.some(n => !Number.isInteger(n) || n < 0 || n > 255))) throw new Error('physics: invalid saved bytes');
24
+ if (!factory) throw new Error('physics: initialize the game before starting its room');
25
+ return factory(mesh, saved);
26
+ }
package/rules/rules.ts CHANGED
@@ -291,7 +291,7 @@ export interface MapBox { readonly min: Vec3; readonly max: Vec3 }
291
291
  export interface MapCircle { readonly at: Vec3; readonly r: number }
292
292
  export interface MapHeightTile { readonly base?: number; readonly diagonal?: '00-11' | '10-01'; readonly at: Vec3; readonly size: Readonly<{ x: number; y: number }>; readonly heights: readonly [number, number, number, number] }
293
293
  export type MapWrap = Readonly<Partial<Record<'x' | 'y' | 'z', readonly [number, number]>>>;
294
- export interface GameMap { readonly tiles?: Tiles; readonly wrap?: MapWrap; readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
294
+ export interface GameMap { readonly physics?: import('./physics.ts').PhysicsMesh; readonly tiles?: Tiles; readonly wrap?: MapWrap; readonly name: string; readonly heightTiles: readonly MapHeightTile[]; readonly bounds: MapBox; readonly boxes: readonly MapBox[]; readonly circles: readonly MapCircle[]; readonly spheres: readonly MapCircle[]; readonly capsules: readonly (MapCircle & { height: number })[]; readonly spots: Readonly<Record<string, readonly Vec3[]>> }
295
295
 
296
296
  const vecOf = (a: unknown, what: string): Vec3 => {
297
297
  const p = Array.isArray(a) ? { x: a[0], y: a[1], z: a[2] ?? 0 } : a as { x?: unknown; y?: unknown; z?: unknown } | null;
@@ -340,7 +340,16 @@ export function compileMap(raw: unknown, name = 'main'): GameMap {
340
340
  }
341
341
  const tiled=compileTiles(m.tiles,wrap);
342
342
  if(tiled) for(const k of ['x','y'] as const) if(wrap?.[k] && (tiled.tiles.at[k]!==bounds.min[k] || tiled.tiles.at[k]+tiled.tiles.size*(k==='x'?tiled.tiles.width:tiled.tiles.height)!==bounds.max[k])) throw new Error(`${where}: tiles must span each wrapped axis`);
343
+ let physics: import('./physics.ts').PhysicsMesh | undefined;
344
+ if (m.physics !== undefined) {
345
+ const {vertices, indices} = m.physics ?? {};
346
+ if (!Array.isArray(vertices) || !Array.isArray(indices) || !vertices.length || vertices.length % 3 || !indices.length || indices.length % 3 ||
347
+ vertices.some((n: unknown) => typeof n !== 'number' || !Number.isFinite(n) || !Number.isFinite(Math.fround(n))) ||
348
+ indices.some((n: unknown) => !Number.isSafeInteger(n) || (n as number) < 0 || (n as number) >= vertices.length / 3)) throw new Error(`${where}: physics needs a build-time baked triangle mesh`);
349
+ physics = Object.freeze({vertices:Object.freeze([...vertices]),indices:Object.freeze([...indices])});
350
+ }
343
351
  const result = Object.freeze({
352
+ ...(m.physics ? {physics: physics!} : {}),
344
353
  ...(tiled?{tiles:tiled.tiles}:{}),
345
354
  name, ...(wrap ? {wrap} : {}),
346
355
  heightTiles: Object.freeze((m.heightTiles ?? []).map((t: any, i: number) => {
@@ -0,0 +1,39 @@
1
+ import { brand, charge, own } from './guard.ts';
2
+
3
+ export interface Substep { readonly dt: number; readonly index: number; readonly count: number; readonly first: boolean; readonly fraction: number }
4
+ export interface StepPose { pos: {x:number;y:number;z:number}; vel: {x:number;y:number;z:number}; heading: {x:number;y:number;z:number}; grounded: boolean }
5
+ const traces = new WeakMap<object, readonly StepPose[]>();
6
+ const running = new WeakSet<object>();
7
+ const scalar = (value: unknown): number => typeof value === 'number' && Number.isFinite(value) ? value : 0;
8
+ const vector = (value: unknown) => ({x:scalar(own(value,'x')),y:scalar(own(value,'y')),z:scalar(own(value,'z'))});
9
+ const pose = (body: object): StepPose => ({pos:vector(own(body,'pos')),vel:vector(own(body,'vel')),heading:vector(own(body,'heading')),grounded:own(body,'grounded') === true});
10
+
11
+ /** Same integration and trace accounting on authority and prediction; no independent quota. */
12
+ export function substeps(tickHz: number) {
13
+ return (body: object, hz: number, step: (context: Substep) => void): void => {
14
+ charge(8);
15
+ const count = typeof hz === 'number' ? hz / tickHz : NaN;
16
+ if (typeof hz !== 'number' || !Number.isSafeInteger(count) || count < 1) throw new RangeError('substeps: hz must be a positive integer multiple of room.tickHz');
17
+ if (!body || typeof body !== 'object' || typeof step !== 'function') throw new TypeError('substeps: expected body, hz, callback');
18
+ if (running.has(body) || traces.has(body)) throw new Error('substeps: integrate a body only once per move');
19
+ // Pay before allocation and callbacks. Each pose copies ten scalars and three vectors.
20
+ charge(64 * (count + 1));
21
+ const samples: StepPose[] = [pose(body)];
22
+ running.add(body);
23
+ traces.delete(body);
24
+ try {
25
+ for (let index = 0; index < count; index++) {
26
+ step(brand(Object.freeze({dt:1/hz,index,count,first:index === 0,fraction:(index+1)/count})));
27
+ samples.push(pose(body));
28
+ }
29
+ traces.set(body, samples);
30
+ } finally { running.delete(body); }
31
+ };
32
+ }
33
+
34
+ /** Runtime-only drawing data, never an extra part of a room save or wire frame. */
35
+ export function takeSubsteps(body: object): readonly StepPose[] | undefined {
36
+ const samples = traces.get(body);
37
+ traces.delete(body);
38
+ return samples;
39
+ }
package/rules/types.ts CHANGED
@@ -28,6 +28,7 @@ export interface MapView { tile(x:number,y:number):number; distance(flow:string,
28
28
  export interface Clock { readonly tick: number; readonly dt: number; ticks(seconds: number): number; readonly math: typeof math }
29
29
  export interface Round { readonly n: number; readonly phase: 'live' | 'over'; readonly endsAt: number }
30
30
  export type World<E, F, S, V, C, A, T, Room extends boolean = false> = Clock & {
31
+ readonly physics: import('./physics.ts').RulesPhysics;
31
32
  readonly tune: ReadonlyState<T>; readonly map: MapView & (Room extends true ? {replace(data:Record<string,unknown>):void} : {}); readonly stage: string; readonly level: number; readonly levelMax: number; readonly guideLevel: number; readonly guideSeats: readonly number[]; readonly kids: boolean; readonly levelSet: boolean;
32
33
  readonly shared: Room extends true ? S : ReadonlyState<S>;
33
34
  readonly round: Round & (Room extends true ? { end(): void; restart(): void } : {});
@@ -76,4 +77,4 @@ export type Entity<W, S, I, E, C, V, Answer = BuiltIns['answer'], G = Goal, A =
76
77
  };
77
78
  export interface MoveBody<M> { get pos(): Vec3; set pos(value: Point); get vel(): Vec3; set vel(value: Point); get heading(): Vec3; set heading(value: Point); grounded: boolean; readonly motion: M }
78
79
  export interface MoveCollision<M> extends RayQueries { sweep(body: MoveBody<M>, delta: Point, options?: {ground?: boolean}): Hit | undefined; support(body: MoveBody<M>, distance?: number): (Hit & {dist: number}) | undefined; overlaps(body: MoveBody<M>): boolean }
79
- export type MoveContext<T, M> = Clock & { readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
80
+ export type MoveContext<T, M> = Clock & { readonly physics: import('./physics.ts').PhysicsMovement; substeps(body: MoveBody<M>, hz: number, step: (context: import('./substeps.ts').Substep) => void): void; readonly tune: ReadonlyState<T>; readonly map: MapView & MoveCollision<M>; readonly world: MoveCollision<M> };
package/rules/view.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { disposeMoveContext } from './math.ts';
1
2
  import { boundPosition, periodicDelta, blendPosition, relativePoint, wrapPoint } from './topology.mjs';
2
3
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
3
4
  import { solidAt } from './collision.ts';
@@ -114,6 +115,8 @@ export interface OpenRoomOptions {
114
115
  /** What a predictor (`openRoom({ predict })`) is handed beside the body and the tick's input. */
115
116
  export interface PredictContext extends RayQueries {
116
117
  readonly tick: number; readonly dt: number;
118
+ /** Read the authoritative snapshot this prediction started from, without advancing time. */
119
+ each(kind: string, fn: (e: Entity) => void): void;
117
120
  /** The same deterministic `math` a `move` is handed (0.46.5): a helper shared with the rules can be called with `ctx`. */
118
121
  readonly math: typeof math;
119
122
  /**
@@ -377,7 +380,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
377
380
  mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion), fields: predictors[u.kind] ? thawFields(kindOf.get(u.kind)?.fields ?? [], u.fields) : u.fields };
378
381
  }
379
382
  /** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
380
- function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
383
+ function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body & {steps?: readonly import('./substeps.ts').StepPose[]} {
381
384
  const fn = moves[kindName];
382
385
  if (!fn || (kindOf.get(kindName)?.control && !body.motion[kindOf.get(kindName)!.control!])) return body;
383
386
  moveTick = t;
@@ -393,6 +396,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
393
396
  /* ---------------------------------------------------------------- the own actions, predicted (0.46.3) */
394
397
 
395
398
  const predictors = opts.predict ?? {};
399
+ let predicting = false;
400
+ let predictionFrame: Frame | null = null;
401
+ function predictionEach(kind: string, fn: (e: Entity) => void): void {
402
+ for (const u of (predictionFrame ?? latest)?.ents.values() ?? []) if (u.kind === kind) fn(entityOf(u, u.id === mine?.id));
403
+ }
396
404
  interface Ghost { id: string; kind: string; tick: number; pos: Vec3; fields: Record<string, unknown>; until: number; match?: (e: Entity) => boolean; /** The server's entity that confirmed it: the ghost is drawn until that one is. */ real?: string }
397
405
  const ghosts = new Map<string, Ghost>();
398
406
  let ghostSeq = 0;
@@ -422,7 +430,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
422
430
  // Branded as a world is (0.46.5): a guarded helper of the rules may call its queries, so the server's own aiming
423
431
  // code can be run on it instead of being restated in the view.
424
432
  const ctx: PredictContext = brand({
425
- tick: t, dt: 1 / tickHz, preview, math,
433
+ tick: t, dt: 1 / tickHz, preview, math, each: predictionEach,
426
434
  judgedTick: (aimTick) => Math.max(t - rewindAllowance(), Math.min(t, Number(aimTick) || 0)),
427
435
  get viewTick() { return viewTickNow(); },
428
436
  ray: (from, direction, max, options) => viewRay(from, direction, max, options)[0],
@@ -451,7 +459,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
451
459
  });
452
460
  const me = Object.assign(fields, { id, kind: kindName }) as Record<string, unknown>;
453
461
  for (const [key, value] of Object.entries({ pos: body.pos, vel: body.vel, heading: body.heading, grounded: body.grounded, motion: body.motion })) Object.defineProperty(me, key, { value, enumerable: false, configurable: true, writable: true });
462
+ const wasPredicting = predicting, previousFrame = predictionFrame;
463
+ predicting = true; predictionFrame = latest;
454
464
  try { fn(me, input, ctx); } catch (err) { console.warn('[room] predict', err); }
465
+ finally { predicting = wasPredicting; predictionFrame = previousFrame; }
455
466
  // What it wrote is held to the declared types, as the server holds its own writes.
456
467
  for (const [name, fd] of kind.fields) fields[name] = coerce(fd, fields[name], dims);
457
468
  for (const key of Object.keys(fields)) if (!kind.fields.some(([name]) => name === key)) delete fields[key];
@@ -554,7 +565,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
554
565
  let pumping = false;
555
566
  function pump(): void {
556
567
  // A predictor's own queries read the room, and reading the room pumps: a step is never begun inside a step.
557
- if (closed || pumping) return;
568
+ if (closed || pumping || predicting) return;
558
569
  pumping = true;
559
570
  try { pumpOnce(); } finally { pumping = false; }
560
571
  }
@@ -588,6 +599,11 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
588
599
  }
589
600
 
590
601
  function onSnapshot(s: Snapshot<unknown>): void {
602
+ const wasPumping = pumping;
603
+ pumping = true;
604
+ try { applySnapshot(s); } finally { pumping = wasPumping; }
605
+ }
606
+ function applySnapshot(s: Snapshot<unknown>): void {
591
607
  const f = frameOf(s);
592
608
  if (!f) return;
593
609
  const before = latest;
@@ -810,6 +826,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
810
826
  * drawn frame, not on the next tick.
811
827
  */
812
828
  function meNow(): Entity | null {
829
+ if (predicting) { const u = mine && predictionFrame?.ents.get(mine.id); return u ? entityOf(u, true) : null; }
813
830
  if (solo) return solo.me;
814
831
  if (!mine || !latest) return null;
815
832
  const server = latest.ents.get(mine.id);
@@ -842,6 +859,14 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
842
859
  velocity = vec3({ x: lerp(mine.vel.x, next.vel.x, a), y: lerp(mine.vel.y, next.vel.y, a), z: lerp(mine.vel.z, next.vel.z, a) }, dims);
843
860
  // A takeoff is airborne as soon as it rises; a landing waits for its contact.
844
861
  grounded = landed || mine.grounded && (a === 0 || next.grounded);
862
+ if (next.steps?.length) {
863
+ const n = next.steps.length - 1, at = a * n, index = Math.min(n - 1, Math.floor(at)), f = at - index;
864
+ const before = next.steps[index], after = next.steps[index + 1];
865
+ pos = vec3(blendPosition(before.pos, after.pos, f, map.wrap), dims);
866
+ heading = blendHeading(before.heading, after.heading, f, dims);
867
+ velocity = vec3({x:lerp(before.vel.x,after.vel.x,f),y:lerp(before.vel.y,after.vel.y,f),z:lerp(before.vel.z,after.vel.z,f)},dims);
868
+ grounded = f === 1 ? after.grounded : before.grounded && after.grounded;
869
+ }
845
870
  preview = next;
846
871
  if (predictors[kind.name]) {
847
872
  // The own actions between ticks: the predictor on a copy of the fields, for the tick about to be stepped.
@@ -1055,6 +1080,6 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1055
1080
  askButtons(id, offer = {}) { if (solo) return []; const e = latest?.ents.get(id); return e && e.seat !== undefined ? agents?.askButtons(e.seat, offer) ?? [] : []; },
1056
1081
  tune, get map(){return solo ? solo.map : mapAt(latest?.k??0);}, net: net as Netplay,
1057
1082
  pump,
1058
- close() { stopSolo(); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
1083
+ close() { stopSolo(); disposeMoveContext(moveCtx as object); closed = true; if (timer) clearInterval(timer); if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', onShow); agents?.stop(); net.close(); setStatus('closed'); },
1059
1084
  };
1060
1085
  }
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.47.0","sha256":"4e1a1a503557e68e60b55a05dc289e4e688831c429d18c8f13ad2a24c3e05315"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.48.0","sha256":"3c0bdb436a2a600fc529827f340b470cdce1cfa40f7d692ff0b9963b5b07c622"});
package/worker/hosted.mjs CHANGED
@@ -19,6 +19,7 @@ import { compileMap, compileRules } from '../rules/rules.ts';
19
19
  /** game id → { rules, tune, map, settings, seats, build } as the build wrote it, and what compiling each gave. */
20
20
  const games = new Map();
21
21
  const compiled = new Map();
22
+ const readiness = new WeakMap();
22
23
 
23
24
  /** The table of this studio's server-hosted games (the build's site/src/rules/index.mjs). Called once, at load. */
24
25
  export function hostRules(table) {
@@ -33,6 +34,21 @@ export function hostRules(table) {
33
34
  export const hostedGame = (id) => games.has(id);
34
35
  export const hostedBuild = (id) => games.get(id);
35
36
 
37
+ /** Optional engines load at room start. Ordinary games keep their synchronous startup path. */
38
+ export function prepareHost(id) {
39
+ const entry = games.get(id);
40
+ if (!entry?.initialize) return;
41
+ const previous = readiness.get(entry);
42
+ if (previous === true) return;
43
+ if (previous) return previous;
44
+ const pending = Promise.resolve().then(() => entry.initialize()).then(
45
+ () => { readiness.set(entry, true); },
46
+ error => { readiness.delete(entry); throw error; },
47
+ );
48
+ readiness.set(entry, pending);
49
+ return pending;
50
+ }
51
+
36
52
  /** A game's rules, compiled once per isolate. Throws, naming the declaration, when they do not fit the contract. */
37
53
  export function compiledFor(id) {
38
54
  if (compiled.has(id)) return compiled.get(id);
package/worker/index.mjs CHANGED
@@ -89,7 +89,7 @@ import { appRole, openPath } from './app-format.mjs';
89
89
  */
90
90
  import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
91
91
  import { NetRoom, WATCH_POLICIES, departure, errorLine, versionOf } from './room.mjs';
92
- import { hostedGame, hostedBuild, compiledFor, startHost } from './hosted.mjs';
92
+ import { hostedGame, hostedBuild, compiledFor, prepareHost, startHost } from './hosted.mjs';
93
93
  import { roomStore, restoreDelay, LOOSE_SAVE } from './room-store.mjs';
94
94
  import { appCors, isAppOrigin } from './standalone.mjs';
95
95
  import { ROOM_ID, badRoomPage, embedAncestors, embedPreview, frameAncestors, noWatchPage, playPage, shellKey, shellStandIn, watchPage } from './pages.mjs';
@@ -1592,13 +1592,13 @@ export class Table {
1592
1592
  this.say({ ev: 'persist-failed', build: hostedBuild(this.game)?.build, error: this.storageHealth.message });
1593
1593
  }
1594
1594
 
1595
- readRoom(validateHost = false) {
1595
+ readRoom(validateHost = false, alreadyRead) {
1596
1596
  if (this.skipSave) { this.skipSave = false; return null; }
1597
1597
  try {
1598
1598
  const boot = this.durable?.boots();
1599
1599
  this.persistentStored = boot?.persistent === true;
1600
1600
  if (boot?.retired === true) { this.clearRoom(); return null; }
1601
- const saved = this.durable?.read();
1601
+ const saved = alreadyRead ?? this.durable?.read();
1602
1602
  if (!saved) return null;
1603
1603
  if (saved.v !== 1 || typeof saved.game !== 'string' || typeof saved.room !== 'string' ||
1604
1604
  this.game && saved.game !== this.game || this.code && saved.room !== this.code ||
@@ -1759,7 +1759,18 @@ export class Table {
1759
1759
  if (!room || !this.hostRt) {
1760
1760
  if (this.ctx.storage.sql?.exec("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'save'").toArray().length) {
1761
1761
  this.durable ??= this.openStore(false);
1762
- const saved = this.cleanupPending ? null : this.readRoom(true);
1762
+ let saved = this.cleanupPending ? null : this.readRoom();
1763
+ if (saved) {
1764
+ const generation = this.cleanupGeneration;
1765
+ const preparing = prepareHost(saved.game);
1766
+ if (preparing) {
1767
+ await preparing;
1768
+ // A join or office action can arrive while the optional engine loads.
1769
+ // Re-read that lifecycle instead of writing the pre-await checkpoint.
1770
+ if (this.room !== room || this.hostRt || this.cleanupGeneration !== generation) return this.runRoomAlarm();
1771
+ }
1772
+ saved = this.readRoom(true, saved);
1773
+ }
1763
1774
  if (saved) {
1764
1775
  this.game = saved.game; this.code = saved.room;
1765
1776
  if (this.isPersistent()) return;
@@ -2167,6 +2178,8 @@ export class Table {
2167
2178
  if (url.pathname === '/__recall') {
2168
2179
  const { account } = await request.json();
2169
2180
  return this.withCharacter(account, async () => {
2181
+ const preparing = prepareHost(game);
2182
+ if (preparing) await preparing;
2170
2183
  this.ensureHost(room, true);
2171
2184
  if (!this.hostRt || this.persistentFailed || room.hostFailed) return json({ ok: false }, 503);
2172
2185
  await this.flushCharacters();
@@ -2232,6 +2245,8 @@ export class Table {
2232
2245
  const said = url.searchParams.has('cur');
2233
2246
  if (said) { room.setStall(url.searchParams.get('stall')); room.setCurrent(url.searchParams.get('cur') || null); }
2234
2247
  // Restore before applying the Worker's current policy: a save must not replace a newer policy.
2248
+ const preparing = prepareHost(game);
2249
+ if (preparing) await preparing;
2235
2250
  this.ensureHost(room, url.searchParams.get('host') === 'server');
2236
2251
  // The room's policy (section 17), composed by the Worker for every socket: a newer one than the room's applies.
2237
2252
  const pol = decodeFacts(url.searchParams.get('pol'));
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.47.0';
2
+ export const STUDIO_VERSION_TAG = '0.48.0';