@homie-rocks/studio 0.43.0 → 0.44.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,56 @@
1
+ import {createHash} from 'node:crypto';
1
2
  import {refreshWorkerConfig} from './worker-config.mjs';
2
3
  import {pathToFileURL} from 'node:url';
3
- import {existsSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
4
- import {join,relative} from 'node:path';
4
+ import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
5
+ import {join,relative,basename,sep} from 'node:path';
5
6
  import {readConfig} from './routes.mjs';
7
+ import {workerDir} from './studio.mjs';
6
8
  import {paidReleases} from './parts-upload.mjs';
7
9
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
8
10
  export async function buildWorker(root,esbuild) {
9
11
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
10
12
  const out=join(root,'site/src/runtime');
11
- refreshWorkerConfig(root);
13
+
12
14
  const alias={...(readConfig(root)?.alias??{})};
13
15
  const toolModule=join(root,'site/src/tools/index.mjs');
14
16
  const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
15
17
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
16
18
  const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
17
- rmSync(out,{recursive:true,force:true});
19
+ // A watcher may already have read the previous entry while this build is
20
+ // publishing. Keep its entire graph available. Publish immutable modules first,
21
+ // then switch the single entry atomically; never remove a running graph.
22
+ const generation=createHash('sha256');
23
+ for(const file of result.outputFiles)generation.update(basename(file.path)).update(file.contents);
24
+ const id=generation.digest('hex').slice(0,24),dir=join(out,id);
18
25
  mkdirSync(out,{recursive:true});
19
- for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
20
- const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
26
+ if(!existsSync(dir)) {
27
+ const stage=mkdtempSync(join(out,'.generation-'));
28
+ try {
29
+ for(const file of result.outputFiles)writeFileSync(join(stage,basename(file.path)),file.contents);
30
+ try {renameSync(stage,dir);} catch(error) {if(!existsSync(dir))throw error;}
31
+ } finally {rmSync(stage,{recursive:true,force:true});}
32
+ }
33
+ const entry=`export * from './${id}/worker.js';\nexport {default} from './${id}/worker.js';\n`;
34
+ const target=join(out,'worker.js');
35
+ if(!existsSync(target)||readFileSync(target,'utf8')!==entry) {
36
+ const temp=join(out,`.worker-${process.pid}.tmp`);
37
+ writeFileSync(temp,entry);renameSync(temp,target);
38
+ }
39
+ // Wrangler's module root is the immutable generation, so old generations are
40
+ // retained locally for in-flight reloads but are never added to a deployment.
41
+ refreshWorkerConfig(root,{main:relative(workerDir(root),join(dir,'worker.js'))});
42
+ // Dev sometimes watches a copy with routes/remote AI removed. Publish its
43
+ // entry too, keeping its local bindings and its module root at one generation.
44
+ const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
45
+ if(existsSync(local)) {
46
+ const config=JSON.parse(readFileSync(local,'utf8'));
47
+ if(typeof config.main==='string' && config.main.startsWith(out+sep)) {
48
+ config.main=join(dir,'worker.js');
49
+ const text=JSON.stringify(config,null,2)+'\n';
50
+ if(readFileSync(local,'utf8')!==text) {const temp=`${local}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,local);}
51
+ }
52
+ }
53
+ const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,join(dir,basename(path))),bytes:value.bytes,imports:value.imports}));
21
54
  writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
22
55
  return sizes;
23
56
  }
@@ -1,4 +1,4 @@
1
- import {readFileSync,writeFileSync,existsSync} from 'node:fs';
1
+ import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
4
4
  import {wranglerConfig} from './scaffold.mjs';
@@ -14,7 +14,7 @@ function merge(base,custom) {
14
14
  export function updateWorkerConfig(root,generated) {
15
15
  const file=configPath(root),next=merge(parse(generated),read(join(root,'wrangler.custom.json')));
16
16
  const text='// Generated by homie-studio. Edit studio.json or wrangler.custom.json, then build.\n'+JSON.stringify(next,null,2)+'\n';
17
- if(!existsSync(file)||readFileSync(file,'utf8')!==text)writeFileSync(file,text);
17
+ if(!existsSync(file)||readFileSync(file,'utf8')!==text) {const temp=`${file}.${process.pid}.tmp`;writeFileSync(temp,text);renameSync(temp,file);}
18
18
  }
19
19
  export function refreshWorkerConfig(root,overrides={}) {
20
20
  const studio=readStudio(root),cf=studio.cloudflare;
@@ -2628,6 +2628,9 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
2628
2628
  setRole(next, wasOffline && roleKnown ? 'reconnected' : String(m['why'] ?? 'welcome'), { ckpt, snap, round: roundInfo, roster: slots });
2629
2629
  if (next === 'host' && snap) tick = Math.max(tick, snap.k);
2630
2630
  }
2631
+ // A reconnect to the same role emits no role event, but its welcome is a new
2632
+ // connection's keyframe. Rules views must adopt it before the first delta.
2633
+ if (rulesGame && snap && next !== 'host') emit('snapshot', snap);
2631
2634
  if (continuing) { rulesHost?.sync(rulesPeers()); reannounce(); catchUp(had, hadSeat); }
2632
2635
  if (link === 'online') paintLink(); else setLink('online', 'welcome');
2633
2636
  // A build older than the live one, let in to its own room (section 23).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.43.0",
3
+ "version": "0.44.1",
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",
@@ -121,7 +121,8 @@
121
121
  "apps/",
122
122
  "app-starters/",
123
123
  "tools/",
124
- "functions/"
124
+ "functions/",
125
+ "guides/"
125
126
  ],
126
127
  "engines": {
127
128
  "node": ">=22"
@@ -160,6 +161,7 @@
160
161
  "zod": "4.3.6"
161
162
  },
162
163
  "scripts": {
163
- "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
164
+ "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
165
+ "prepack": "node ../../scripts/authoring-guides.mjs --check"
164
166
  }
165
167
  }
@@ -14,7 +14,7 @@ export interface Map3 {
14
14
  capsules?: readonly (MapCircle & { height: number })[];
15
15
  }
16
16
  const EPS = 1e-7;
17
- const point = (x: number, y: number, z: number): Vec3 => ({ x, y, z });
17
+ const point = (x: number, y: number, z: number): {x: number; y: number; z: number} => ({ x, y, z });
18
18
  export function solidAt(at: Vec3, body: BodyShape): Solid {
19
19
  const r = body.radius, height = body.height || 2 * r;
20
20
  if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
@@ -93,7 +93,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
93
93
  charge(24);
94
94
  const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
95
95
  let best: Hit3 | null = null;
96
- const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
96
+ const take = (h: Hit3 | null, id?: string): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = id ? {...h, id} : h; };
97
97
  for (const axis of ['x', 'y', 'z'] as const) {
98
98
  const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
99
99
  const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
@@ -102,11 +102,11 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
102
102
  take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
103
103
  }
104
104
  for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
105
- for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
105
+ for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
106
106
  // Legacy map circles are vertical columns in a 3D map.
107
- for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }));
108
- for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
109
- for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
107
+ for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
108
+ for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }), (s as any).id);
109
+ for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })), (c as any).id);
110
110
  return best;
111
111
  }
112
112
  export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
package/rules/core.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
1
2
  /*
2
3
  * core.ts — one room's world, stepped a tick at a time from a compiled rules module.
3
4
  * =============================================================================
@@ -41,7 +42,7 @@
41
42
  import { castMap3, castSolid, restsOnMap, solidAt, type BodyShape, type Hit3 } from './collision.ts';
42
43
  import { argsWhy, type Vocabulary } from '../agents/agents.ts';
43
44
  import { BudgetError, G, brand, charge, deepFreeze, plainData } from './guard.ts';
44
- import { SKIN, castMap, exact, math, rayCircle, sweepMap } from './math.ts';
45
+ import { SKIN, castMap, exact, math, rayCircle } from './math.ts';
45
46
  import type { Hit } from './math.ts';
46
47
  import { AHEAD, ZERO, coerce, coerceFields, dir, est, estFields, initFields, mutable, num, own, packEntity, packFields, packVec, packed, said, thaw, naming, unpackFields, unpackVec, vec3 } from './pack.ts';
47
48
  import { ENTITY_MAX, QUEUE_MAX, REACH_M, SAVE_REVISION, cellsOf } from './rules.ts';
@@ -178,7 +179,7 @@ export interface Core {
178
179
  * `noted`: told, as it happens, of a value the rules wrote that the runtime changed to make it fit, or dropped (pack.ts
179
180
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
180
181
  */
181
- export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody) => 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 {
182
+ export function createCore(c: Compiled, opts: { 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) => 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 {
182
183
  const dims = c.dims;
183
184
  const tickHz = c.settings.tickHz;
184
185
  const dt = 1 / tickHz;
@@ -483,7 +484,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
483
484
  const solid = solidAt(p, shape);
484
485
  for (const other of ents.values()) {
485
486
  if (!other.kind.body || ignored.has(other.id)) continue;
486
- const hit = castSolid(solid, d, solidAt(other.pos, other.kind.body));
487
+ if (other.kind.collider) charge(48);
488
+ const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
489
+ if (other.dead || other.kind.collider && !row) continue;
490
+ const hit = castSolid(solid, d, row ? colliderSolid(row) : solidAt(other.pos, other.kind.body));
487
491
  if (hit && (!best || hit.t < best.t)) best = { ...hit, id: other.id };
488
492
  }
489
493
  return best;
@@ -618,7 +622,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
618
622
  let best: Hit | null = cast.hit;
619
623
  for (const e of ents.values()) {
620
624
  if (!e.kind.body || e === cx.ent) continue;
621
- const h = rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
625
+ if (e.kind.collider) charge(48);
626
+ const row = e.kind.collider ? colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f) : null;
627
+ if (e.dead || e.kind.collider && !row) continue;
628
+ const h = row ? castCollider2(row, p, {x:d.x*far,y:d.y*far,z:0}, 0) : rayCircle(p.x, p.y, d.x * far, d.y * far, e.pos.x, e.pos.y, e.kind.body.radius);
622
629
  if (h && (!best || h.t < best.t)) best = { ...h, id: e.id };
623
630
  }
624
631
  if (!best) return undefined;
@@ -648,7 +655,10 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
648
655
  let best: Hit | null = cast.hit;
649
656
  for (const other of ents.values()) {
650
657
  if (other === e || !other.kind.body || ignore.has(other.id)) continue;
651
- const h = rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
658
+ if (other.kind.collider) charge(48);
659
+ const row = other.kind.collider ? colliderRow(other.id, other.pos, other.kind.body, other.kind.collider, other.f) : null;
660
+ if (other.dead || other.kind.collider && !row) continue;
661
+ const h = row ? castCollider2(row, e.pos, d, radius) : rayCircle(e.pos.x, e.pos.y, d.x, d.y, other.pos.x, other.pos.y, other.kind.body.radius + radius);
652
662
  if (h && (!best || h.t < best.t)) best = { ...h, id: other.id };
653
663
  }
654
664
  const l = Math.sqrt(d.x * d.x + d.y * d.y);
@@ -678,11 +688,19 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
678
688
  });
679
689
  Object.freeze(world);
680
690
 
681
- const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, sweep: (body: unknown, delta: unknown): unknown => sweepMap(c.map, body, delta, moveRadius, dims, moveShape) }));
682
- let moveRadius = 0;
691
+ const hasColliders = c.kinds.some(k => k.collider);
692
+ const collisionState = (): CollisionRevision => [tick, tick + 1, [...ents.values()].flatMap(e => {
693
+ if (e.dead || !e.kind.collider || !e.kind.body) return [];
694
+ const row = colliderRow(e.id, e.pos, e.kind.body, e.kind.collider, e.f);
695
+ return row ? [row] : [];
696
+ })];
697
+ let moveGeometry = c.map as import('./math.ts').MapShapes;
698
+ const queries = collisionQueries(() => moveGeometry, () => moveShape, dims);
699
+ const moveWorld = brand(Object.freeze(queries));
700
+ const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
683
701
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
684
702
  const moveCtx = brand({} as Record<string, unknown>);
685
- for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
703
+ 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 });
686
704
  Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
687
705
  Object.freeze(moveCtx);
688
706
  const joinCtx = brand({} as Record<string, unknown>);
@@ -988,6 +1006,8 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
988
1006
  }
989
1007
  }
990
1008
  guides?.();
1009
+ // All movers see the same world, independent of entity iteration order.
1010
+ if (hasColliders) { left -= 64 * ents.size + 4 * mapShapes; moveGeometry = collisionMap(c.map, collisionState()[2]); }
991
1011
  // Phase 1: one input step and `move` for every body.
992
1012
  for (const e of [...ents.values()]) {
993
1013
  if (e.dead || !e.kind.body) continue;
@@ -1040,7 +1060,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1040
1060
  if (!k.move) continue;
1041
1061
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1042
1062
  const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
1043
- moveRadius = body.radius; moveShape = body;
1063
+ moveShape = body;
1044
1064
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
1045
1065
  // The runtime rounds to 32-bit floats, here and in the browser, so both step from exactly the same numbers.
1046
1066
  // `b` is the runtime's own object: what `move` left in it is read by its own data properties and cannot throw.
@@ -1049,7 +1069,7 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1049
1069
  naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
1050
1070
  G.note = null;
1051
1071
  e.grounded = own(b, 'grounded') === true;
1052
- if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } });
1072
+ if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry);
1053
1073
  }
1054
1074
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
1055
1075
  const list = [...ents.values()];
@@ -1369,7 +1389,9 @@ export function createCore(c: Compiled, opts: { moved?: (kind: string, tick: num
1369
1389
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1370
1390
  // Include all three components of position, velocity and heading in a 3D snapshot.
1371
1391
  snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1372
- return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list];
1392
+ const collision = hasColliders ? collisionState() : null;
1393
+ if (collision) snapCells += 3 + collision[2].length * 8;
1394
+ return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1373
1395
  },
1374
1396
  shared: () => packFields(c.shared, shared, dims),
1375
1397
  bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
package/rules/host.ts CHANGED
@@ -99,6 +99,7 @@ export interface Host {
99
99
  readonly running: boolean;
100
100
  readonly paused: boolean;
101
101
  readonly people: number;
102
+ readonly viewRadiusM: number | null;
102
103
  frame(m: Record<string, unknown>): void;
103
104
  /** Start the tick loop (the first person has joined). */
104
105
  start(): void;
@@ -767,6 +768,7 @@ export function createHost(o: HostOptions): Host {
767
768
  }
768
769
 
769
770
  return {
771
+ get viewRadiusM() { return c.settings.view?.radiusM ?? null; },
770
772
  get tick() { return core.tick; },
771
773
  get epoch() { return epoch; },
772
774
  get running() { return running; },
@@ -0,0 +1,81 @@
1
+ /** Per-connection state delivery. Pure data, shared by the relay and the view.
2
+ * Deltas name a keyframe, never an unacknowledged previous delta. Losing a delta
3
+ * therefore loses no baseline. A new keyframe bounds recovery to one second.
4
+ * No history grows with time: one keyframe per connection, no per-frame queue.
5
+ */
6
+ export function interestSnapshot(snap, seat, radiusM) {
7
+ const [round, entities] = snap.d;
8
+ const own = seat === null ? null : entities.find(e => e[9] === seat && e[10] === 0);
9
+ const visible = own && radiusM !== null ? entities.filter(e => {
10
+ const p = e[3], q = own[3];
11
+ return e === own || (p[0] - q[0]) ** 2 + (p[1] - q[1]) ** 2 + ((p[2] ?? 0) - (q[2] ?? 0)) ** 2 <= radiusM ** 2;
12
+ }) : seat === null ? entities : own ? entities : [];
13
+ // Collision revisions are prediction inputs, independent of visual interest.
14
+ // Deliver all geometry: authored sweeps/dashes need not fit inside the view radius.
15
+ return { ...snap, d: [round, visible, ...snap.d.slice(2)], c: (snap.c ?? []).filter(row => row[0] === seat) };
16
+ }
17
+
18
+ export function snapshotEncoder(keyframeTicks) {
19
+ let base = null;
20
+ let rows = new Map();
21
+ return {
22
+ reset() { base = null; rows.clear(); },
23
+ encode(snap) {
24
+ if (!base || snap.e !== base.e || snap.k <= base.k || snap.k - base.k >= keyframeTicks) {
25
+ base = snap;
26
+ rows = new Map(snap.d[1].map(e => [e[0], e.map(v => JSON.stringify(v))]));
27
+ return snap;
28
+ }
29
+ const present = new Set(), changed = [];
30
+ for (const entity of snap.d[1]) {
31
+ const id = entity[0]; present.add(id);
32
+ const before = rows.get(id);
33
+ if (!before) { changed.push(entity); continue; }
34
+ let mask = 0; const values = [];
35
+ for (let i = 1; i < entity.length; i++) if (JSON.stringify(entity[i]) !== before[i]) { mask |= 1 << i; values.push(entity[i]); }
36
+ if (mask) changed.push([id, mask, values]);
37
+ }
38
+ return { ...snap, d: { base: base.k, round: snap.d[0], ...(snap.d.length > 2 ? { collision: snap.d[2] } : {}), changed, removed: [...rows.keys()].filter(id => !present.has(id)) } };
39
+ },
40
+ };
41
+ }
42
+
43
+ export function snapshotDecoder() {
44
+ let base = null;
45
+ let rows = new Map();
46
+ return {
47
+ reset() { base = null; rows.clear(); },
48
+ decode(snap) {
49
+ if (!snap || typeof snap !== 'object') return null;
50
+ if (Array.isArray(snap.d)) {
51
+ if (!Array.isArray(snap.d[0]) || !Array.isArray(snap.d[1]) ||
52
+ !snap.d[1].every(e => Array.isArray(e) && e.length >= 9 && typeof e[0] === 'string')) return null;
53
+ // A late duplicate keyframe must not replace the newer baseline.
54
+ if (!base || snap.e !== base.e || snap.k >= base.k) {
55
+ base = snap; rows = new Map(snap.d[1].map(e => [e[0], e]));
56
+ }
57
+ return snap;
58
+ }
59
+ const d = snap.d;
60
+ if (!base || !d || d.base !== base.k || snap.e !== base.e || snap.k <= base.k) return null;
61
+ if (!Array.isArray(d.round) || !Array.isArray(d.removed) || !Array.isArray(d.changed)) return null;
62
+ const next = new Map(rows);
63
+ for (const id of d.removed) next.delete(id);
64
+ for (const change of d.changed) {
65
+ if (!Array.isArray(change) || typeof change[0] !== 'string') return null;
66
+ if (change.length !== 3) {
67
+ if (change.length < 9) return null;
68
+ next.set(change[0], change); continue;
69
+ }
70
+ if (!Number.isInteger(change[1]) || change[1] < 0 || !Array.isArray(change[2])) return null;
71
+ const old = rows.get(change[0]);
72
+ if (!old) return null;
73
+ const entity = old.slice(); let j = 0;
74
+ for (let i = 1; i < entity.length; i++) if (change[1] & (1 << i)) entity[i] = change[2][j++];
75
+ if (j !== change[2].length) return null;
76
+ next.set(entity[0], entity);
77
+ }
78
+ return { ...snap, d: [d.round, [...next.values()], ...(d.collision === undefined ? [] : [d.collision])] };
79
+ },
80
+ };
81
+ }
package/rules/live.ts ADDED
@@ -0,0 +1,73 @@
1
+ /** Declared collision geometry, shared by authority and prediction. Entity fields
2
+ * own its lifetime and dimensions; this projection contains no gameplay state. */
3
+ import type { Vec3, ColliderDef } from './rules.ts';
4
+ import type { MapShapes } from './math.ts';
5
+ import { charge, own } from './guard.ts';
6
+ import { castMap3, solidAt, type BodyShape } from './collision.ts';
7
+ import { sweepMap, rayBox, rayCircle } from './math.ts';
8
+
9
+ export type ColliderRow = [string, string, number, number, number, number, number, number];
10
+ export type CollisionRevision = [number, number, ColliderRow[]];
11
+ export function colliderRow(id: string, at: Vec3, body: BodyShape, def: ColliderDef, fields: Record<string, unknown>): ColliderRow | null {
12
+ const opts = def === true ? {} : def;
13
+ if (opts.enabled && fields[opts.enabled] !== true) return null;
14
+ const size = opts.size ? fields[opts.size] as Vec3 : null;
15
+ const x = size ? size.x : body.radius * 2, y = size ? size.y : body.radius * 2, z = size ? size.z : body.height || body.radius * 2;
16
+ if (![x, y, z].every(Number.isFinite) || x <= 0 || y <= 0 || z < 0) return null;
17
+ return [id, size ? 'box' : body.shape, at.x, at.y, at.z, x, y, z];
18
+ }
19
+ export function colliderSolid(row: ColliderRow): ReturnType<typeof solidAt> {
20
+ const [, shape, x, y, z, w, d, h] = row;
21
+ return shape === 'box' ? { min: { x: x-w/2, y: y-d/2, z }, max: { x:x+w/2, y:y+d/2, z:z+h }, r:0 }
22
+ : solidAt({x,y,z}, {shape, radius:w/2, height:h});
23
+ }
24
+ export function castCollider2(row: ColliderRow, p: Vec3, d: Vec3, radius: number) {
25
+ const solid = colliderSolid(row);
26
+ return row[1] === 'box' ? rayBox(p.x,p.y,d.x,d.y,solid.min,solid.max,radius) : rayCircle(p.x,p.y,d.x,d.y,row[2],row[3],row[5]/2+radius);
27
+ }
28
+ export function collisionMap(map: MapShapes, rows: readonly ColliderRow[]): MapShapes {
29
+ if (!rows.length) return map;
30
+ const boxes = [...map.boxes], circles = [...map.circles], spheres = [...(map.spheres ?? [])], capsules = [...(map.capsules ?? [])];
31
+ for (const [id, shape, x, y, z, w, d, h] of rows) {
32
+ const at = { x, y, z };
33
+ if (shape === 'box') boxes.push({ min: { x: x - w / 2, y: y - d / 2, z }, max: { x: x + w / 2, y: y + d / 2, z: z + h }, id } as any);
34
+ else if (shape === 'circle') circles.push({ at, r: w / 2, id } as any);
35
+ else if (shape === 'sphere') spheres.push({ at: { x, y, z: z + w / 2 }, r: w / 2, id } as any);
36
+ else capsules.push({ at, r: w / 2, height: h, id } as any);
37
+ }
38
+ return { ...map, boxes, circles, spheres, capsules };
39
+ }
40
+ /** Latest known revision at the requested tick. Unknown future edits are never
41
+ * guessed: a later snapshot rebases the body and replays its pending inputs. */
42
+ export function revisionAt(history: readonly CollisionRevision[], tick: number): CollisionRevision | undefined {
43
+ for (let i = history.length - 1; i >= 0; i--) if (history[i][1] <= tick) return history[i];
44
+ return undefined;
45
+ }
46
+ export function collisionQueries(map: () => MapShapes, shape: () => BodyShape, dims: number): object {
47
+ const position = (body: unknown): Vec3 => {
48
+ const p = own(body, 'pos');
49
+ const n = (k: string): number => { const v = own(p, k); return typeof v === 'number' && Number.isFinite(v) ? Math.fround(v) : 0; };
50
+ return { x: n('x'), y: n('y'), z: n('z') };
51
+ };
52
+ return {
53
+ sweep: (body: unknown, delta: unknown) => sweepMap(map(), body, delta, shape().radius, dims, shape()),
54
+ support: (body: unknown, distance: unknown = 0.002) => {
55
+ charge(20);
56
+ const p = position(body), m = map();
57
+ const d = typeof distance === 'number' && Number.isFinite(distance) ? Math.max(0, Math.min(10000, distance)) : 0;
58
+ if (dims === 2) return Object.freeze({ at: Object.freeze(p), normal: Object.freeze({ x: 0, y: 0, z: 1 }), dist: 0 });
59
+ const h = castMap3(m, p, { x: 0, y: 0, z: -d }, shape());
60
+ return h && h.nz > 0.5 ? Object.freeze({ at: Object.freeze({ ...p, z: p.z - d * h.t }), normal: Object.freeze({ x: h.nx, y: h.ny, z: h.nz }), dist: d * h.t, ...(h.id ? { entity: h.id } : {}) }) : undefined;
61
+ },
62
+ overlaps: (body: unknown) => {
63
+ const m = map(), p = position(body), s = shape(), a = solidAt(p, s);
64
+ charge(20 + 8 * (m.boxes.length + m.circles.length + (m.spheres?.length ?? 0) + (m.capsules?.length ?? 0)));
65
+ const overlap = (b: ReturnType<typeof solidAt>): boolean => {
66
+ let sq = 0;
67
+ for (const k of dims === 2 ? ['x', 'y'] as const : ['x', 'y', 'z'] as const) { const gap = Math.max(0, a.min[k] - b.max[k], b.min[k] - a.max[k]); sq += gap * gap; }
68
+ return sq < (a.r + b.r) ** 2 || a.r + b.r === 0 && a.min.x < b.max.x && a.max.x > b.min.x && a.min.y < b.max.y && a.max.y > b.min.y && (dims === 2 || a.min.z < b.max.z && a.max.z > b.min.z);
69
+ };
70
+ return m.boxes.some(b => overlap({ ...b, r: 0 })) || m.circles.some(b => overlap({ min: {...b.at,z:m.bounds.min.z}, max: {...b.at,z:m.bounds.max.z}, r: b.r })) || (m.spheres ?? []).some(b => overlap({ min: b.at, max: b.at, r: b.r })) || (m.capsules ?? []).some(b => overlap(solidAt(b.at, { shape: 'capsule', radius: b.r, height: b.height })));
71
+ },
72
+ };
73
+ }
package/rules/math.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { collisionQueries } from './live.ts';
1
2
  /*
2
3
  * math.ts — `world.math` and `ctx.math`: the maths rules may not take from `Math`, in plain arithmetic.
3
4
  * =============================================================================
@@ -204,7 +205,7 @@ export function rayCircle(px: number, py: number, dx: number, dy: number, cx: nu
204
205
  return { t, nx: hx / l, ny: hy / l };
205
206
  }
206
207
  /** The same point against a box grown by r with round corners (a circle of radius r against the box). */
207
- function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
208
+ export function rayBox(px: number, py: number, dx: number, dy: number, min: Vec3, max: Vec3, r: number): Hit | null {
208
209
  let t0 = 0; let t1 = 1; let nx = 0; let ny = 0;
209
210
  const lo = [min.x - r, min.y - r]; const hi = [max.x + r, max.y + r];
210
211
  const p = [px, py]; const d = [dx, dy];
@@ -237,8 +238,8 @@ function rayBounds(px: number, py: number, dx: number, dy: number, b: { min: Vec
237
238
  /** A circle (or a point, r 0) moved along d against the static map: the first thing in the way, and how many shapes were tested. */
238
239
  export function castMap(map: MapShapes, px: number, py: number, dx: number, dy: number, r: number): { hit: Hit | null; tested: number } {
239
240
  let best: Hit | null = rayBounds(Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, px)), Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, py)), dx, dy, map.bounds, r);
240
- for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = h; }
241
- for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = h; }
241
+ for (const b of map.boxes) { const h = rayBox(px, py, dx, dy, b.min, b.max, r); if (h && (!best || h.t < best.t)) best = { ...h, id: (b as any).id }; }
242
+ for (const c of map.circles) { const h = rayCircle(px, py, dx, dy, c.at.x, c.at.y, c.r + r); if (h && (!best || h.t < best.t)) best = { ...h, id: (c as any).id }; }
242
243
  return { hit: best, tested: 4 + map.boxes.length + map.circles.length };
243
244
  }
244
245
 
@@ -264,7 +265,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
264
265
  const t = h ? Math.max(0, h.t - (length > 0 ? SKIN / length : 0)) : 1;
265
266
  const at = v(fr(px + dx * t), fr(py + dy * t), fr(p.z + d.z * t));
266
267
  put(body, 'pos', at); put(body, 'grounded', restsOnMap(map, at, shape));
267
- return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz) }) : undefined;
268
+ return h ? Object.freeze({ at, normal: v(h.nx, h.ny, h.nz), ...(h.id ? { entity: h.id } : {}) }) : undefined;
268
269
  }
269
270
  const { hit: h } = castMap(map, px, py, dx, dy, radius);
270
271
  put(body, 'grounded', true);
@@ -273,7 +274,7 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
273
274
  const t = l > 0 ? Math.max(0, h.t - SKIN / l) : 0;
274
275
  const at = v(fr(px + dx * t), fr(py + dy * t), 0);
275
276
  put(body, 'pos', at);
276
- return Object.freeze({ at, normal: v(h.nx, h.ny, 0) });
277
+ return Object.freeze({ at, normal: v(h.nx, h.ny, 0), ...(h.id ? { entity: h.id } : {}) });
277
278
  }
278
279
 
279
280
  /**
@@ -281,12 +282,14 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
281
282
  * map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
282
283
  * server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
283
284
  */
284
- 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 }): unknown {
285
+ 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 }): unknown {
285
286
  const none = Object.freeze([]);
287
+ const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims);
286
288
  return brand(Object.freeze({
287
289
  get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
288
290
  // As the server's `ctx.ticks` reads it: a plain number, or nothing.
289
291
  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; },
290
- map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, sweep: (body: unknown, delta: unknown) => sweepMap(o.map, body, delta, o.radius(), o.dims, o.shape?.()) })),
292
+ world: brand(Object.freeze(queries)),
293
+ map: brand(Object.freeze({ name: o.name, spot: (name: string) => (own(o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(o.spots, name) ?? none, ...queries })),
291
294
  }));
292
295
  }
package/rules/rules.ts CHANGED
@@ -37,7 +37,7 @@ export const SAVE_REVISION = 2;
37
37
  export const REACH_M = 64;
38
38
  /** The most entities one room holds. A spawn past it throws in the handler that asked. */
39
39
  export const ENTITY_MAX = 2048;
40
- /** The most seats one room holds (worker/seats.mjs `SEAT_MAX`). */
40
+ /** The current bot-fill ceiling; public admission lives in worker/seats.mjs. */
41
41
  export const SEATS_MAX = 32;
42
42
  /** The most values one declared field may hold when it is full (`cellsOf`), and the most all the fields of one kind of entity may. */
43
43
  export const FIELD_CELLS_MAX = 16_384;
@@ -123,6 +123,7 @@ export type Handler = (world: RuntimeWorld, self: RuntimeSelf, e?: any) => void;
123
123
  export type RoomHandler = (world: RuntimeWorld, e?: any) => void;
124
124
  export type MoveFn = (body: any, input: any, ctx: any) => void;
125
125
 
126
+ export type ColliderDef = true | { size?: string; enabled?: string };
126
127
  export interface BodyDef { shape: 'circle' | 'sphere' | 'capsule' | 'box'; radius: number; height?: number; maxSpeed: number; sweep?: boolean; move?: 'owner' }
127
128
  export interface GuideDef { view: (world: RuntimeWorld, self: RuntimeSelf) => unknown; floor?: (world: RuntimeWorld, self: RuntimeSelf, view: any) => unknown }
128
129
  export interface EntityDef {
@@ -131,6 +132,7 @@ export interface EntityDef {
131
132
  motion?: Fields;
132
133
  input?: Fields;
133
134
  body?: BodyDef;
135
+ collider?: ColliderDef;
134
136
  guide?: GuideDef;
135
137
  tick?: Handler;
136
138
  think?: (world: RuntimeWorld, self: RuntimeSelf) => Record<string, unknown>;
@@ -176,6 +178,8 @@ export interface RoomSettings {
176
178
  offline: boolean;
177
179
  tickHz: number;
178
180
  inputHz: number;
181
+ /** Omit the radius to send the whole room. No implicit visibility or bandwidth cap. */
182
+ view: { radiusM: number | null };
179
183
  durability: { movementSeconds: number };
180
184
  budget: { tick: number };
181
185
  predict: { catchM: number | null; catchUp: number; snapM: number | null; blendMs: number; interpMs: number | null };
@@ -191,7 +195,7 @@ export const BUDGET_TICK = 500_000;
191
195
  export const BUDGET_SECOND = 10_000_000;
192
196
  export const budgetFor = (tickHz: number): number => Math.min(BUDGET_TICK, Math.floor(BUDGET_SECOND / tickHz));
193
197
  export const ROOM_DEFAULTS: RoomSettings = deepFreeze({
194
- host: 'server', offline: true, tickHz: 20, inputHz: 20, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
198
+ host: 'server', offline: true, tickHz: 20, inputHz: 20, view: { radiusM: null }, durability: { movementSeconds: 1 }, budget: { tick: BUDGET_TICK },
195
199
  predict: { catchM: null, catchUp: 1.25, snapM: null, blendMs: 100, interpMs: null },
196
200
  });
197
201
 
@@ -212,18 +216,24 @@ export function roomSettings(raw: unknown): { settings: RoomSettings; problems:
212
216
  if (!Number.isFinite(n) || n < 0) { problems.push(`"room.${name}" is a number, zero or more; using its default`); return d; }
213
217
  return n;
214
218
  };
215
- const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict']);
216
- for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict)`);
219
+ const known = new Set(['host', 'offline', 'tickHz', 'inputHz', 'durability', 'budget', 'predict', 'view']);
220
+ for (const key of Object.keys(r)) if (!known.has(key)) problems.push(`"room.${key}" is not a setting (host, offline, tickHz, inputHz, durability, budget, predict, view)`);
217
221
  let host: 'server' | 'browser' = 'server';
218
222
  if (r.host !== undefined) { if (r.host === 'server' || r.host === 'browser') host = r.host; else problems.push('"room.host" is "server" or "browser"; using "server"'); }
219
223
  let offline = true;
220
224
  if (r.offline !== undefined) { if (typeof r.offline === 'boolean') offline = r.offline; else problems.push('"room.offline" is true or false; using true'); }
221
225
  const tickHz = whole('tickHz', r.tickHz, 1, 60, 20);
222
226
  const inputHz = whole('inputHz', r.inputHz, 1, tickHz, tickHz);
227
+ if (host === 'browser' && r.view?.radiusM != null) problems.push('"room.view.radiusM" needs room.host "server"; browser hosting sends the whole room');
228
+ if (r.view !== undefined) {
229
+ if (!r.view || typeof r.view !== 'object' || Array.isArray(r.view)) problems.push('"room.view" is an object with an optional radiusM');
230
+ else for (const key of Object.keys(r.view)) if (key !== 'radiusM') problems.push(`"room.view.${key}" is not a setting; use radiusM`);
231
+ }
223
232
  const p = r.predict && typeof r.predict === 'object' ? r.predict : {};
224
233
  return {
225
234
  settings: {
226
235
  host, offline, tickHz, inputHz,
236
+ view: { radiusM: r.view?.radiusM === null ? null : num('view.radiusM', r.view?.radiusM, null) },
227
237
  durability: { movementSeconds: whole('durability.movementSeconds', r.durability?.movementSeconds, 1, 60, 1) },
228
238
  budget: { tick: Math.max(1, Math.floor(num('budget.tick', r.budget?.tick, budgetFor(tickHz)) as number)) },
229
239
  predict: { catchM: num('predict.catchM', p.catchM, null), catchUp: num('predict.catchUp', p.catchUp, 1.25) as number, snapM: num('predict.snapM', p.snapM, null), blendMs: num('predict.blendMs', p.blendMs, 100) as number, interpMs: num('predict.interpMs', p.interpMs, null) },
@@ -307,6 +317,7 @@ export interface KindTable {
307
317
  fields: FieldList;
308
318
  motion: FieldList;
309
319
  input: FieldList;
320
+ collider?: ColliderDef;
310
321
  body: null | { shape: string; radius: number; height: number; maxSpeed: number; sweep: boolean; owner: boolean };
311
322
  score: string | null;
312
323
  tick: Handler | null;
@@ -513,11 +524,22 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
513
524
  if (e.guide !== undefined && (typeof e.guide !== 'object' || typeof e.guide?.view !== 'function')) throw new Error(`${at}.guide needs view(world, self)`);
514
525
  const cells = cellsOfList(fields) + cellsOfList(motion);
515
526
  if (cells > KIND_CELLS_MAX) throw new Error(`${at}: its fields and motion may hold ${cells} values when they are full, and one entity holds ${KIND_CELLS_MAX} at most`);
516
- const k: KindTable = { name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
527
+ if (e.collider !== undefined) {
528
+ if (!body || player || body.owner) throw new Error(`${at}.collider needs a non-player body`);
529
+ if (e.collider !== true) {
530
+ if (!e.collider || typeof e.collider !== 'object' || Object.keys(e.collider).some(k => !['size', 'enabled'].includes(k))) throw new Error(`${at}.collider is true or {size, enabled}`);
531
+ for (const [key, type] of [['size', 'vec3'], ['enabled', 'bit']]) {
532
+ const name = (e.collider as Record<string, unknown>)[key];
533
+ if (name !== undefined && (typeof name !== 'string' || !fields.some(([n, f]) => n === name && f.t === type))) throw new Error(`${at}.collider.${key} must name a ${type} field`);
534
+ }
535
+ }
536
+ }
537
+ const k: KindTable = { ...(e.collider ? { collider: e.collider } : {}), name, index: kinds.length, player, fields, motion, input, body, score: scores[0]?.[0] ?? null, tick: e.tick ?? null, think: e.think ?? null, on, commands: cmds, onRoom, guide: e.guide ?? null, move: moveFn };
517
538
  kinds.push(k);
518
539
  kindOf[name] = k;
519
540
  }
520
541
 
542
+ if (kinds.some(k => k.collider) && kinds.some(k => k.player && k.body?.owner)) throw new Error("live colliders need server movement: omit body.move: 'owner' on players");
521
543
  const room = d.room ?? {};
522
544
  const roomOn = handlers<RoomHandler>(room.on, 'room.on');
523
545
  for (const key of Object.keys(roomOn)) if (!events[key] && !ROOM_EVENTS.includes(key) && !SEAT_EVENTS.includes(key) && key !== 'answer' && key !== 'undeliverable') throw new Error(`room.on.${key}: no event "${key}" is declared in shapes.events`);
@@ -540,7 +562,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
540
562
  if (!a.questions || typeof a.questions !== 'object') throw new Error(`asks.${key}.questions is required; for example { advance: { type: 'noul', instructions: 'Should the party advance?' } }`);
541
563
  asks[key] = { ...a, stateFields: fieldList(a.state, `asks.${key}.state`) };
542
564
  }
543
- const seats = Math.max(1, Math.min(SEATS_MAX, Math.floor(Number(env.seats)) || 8));
565
+ const seats = Math.max(1, Math.min(Number.MAX_SAFE_INTEGER, Math.floor(Number(env.seats)) || 8));
544
566
  return {
545
567
  contract: RULES_CONTRACT, save: SAVE_REVISION, dims, kinds, kindOf, events, commands, effects, effectNames: Object.keys(effects),
546
568
  shared: fieldList(d.shared, 'shared'), view, rounds, bots: Math.min(keep, seats), start: room.start ?? null, join: room.join ?? null, roomOn, asks,