@homie-rocks/studio 0.47.0 → 0.48.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.
@@ -2943,6 +2943,90 @@
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
+ }
2988
+ },
2989
+ "0.48.1": {
2990
+ "files": {
2991
+ "CLAUDE.md": "ddf2b2a0376202f6",
2992
+ "README.md": "684b3cca8001673c",
2993
+ "HANDOFF.md": "29337e2ace95b2e7",
2994
+ "games/README.md": "5ad32fe80c61f117",
2995
+ "music/README.md": "a3dc042dadd39df8",
2996
+ "videos/README.md": "16babc3437d5545b",
2997
+ "posts/README.md": "37841aae476bd540",
2998
+ "site/README.md": "732894273a32dc3f",
2999
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3000
+ },
3001
+ "sections": {
3002
+ "AGENTS.md": {
3003
+ "(intro)": "394cbbe934e80722",
3004
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3005
+ "## Layout": "f3a280b8c96cd068",
3006
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
3007
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
3008
+ "## The site": "0cb8dd9a6f8a0096",
3009
+ "## Making games": "bb715272fd1a615b",
3010
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3011
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3012
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3013
+ "## Servers and AI seats": "1ccfd549d2369a60",
3014
+ "## Selling things (the shop)": "9e020d69dd6a954e",
3015
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3016
+ "## Rules": "27881d11d3ff3803",
3017
+ "## Telling Homie": "80b46fdd9e578db8",
3018
+ "## Beta": "9d02638207a26e73"
3019
+ }
3020
+ },
3021
+ "named": {
3022
+ "Homie Arcade": {
3023
+ "sections": {
3024
+ "AGENTS.md": {
3025
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
3026
+ }
3027
+ }
3028
+ }
3029
+ }
2946
3030
  }
2947
3031
  }
2948
3032
  }
@@ -2329,3 +2329,23 @@ private round imports no room save, match score or simulation state into the onl
2329
2329
  room. When a body is assigned, the private round closes and the view joins the
2330
2330
  online match. Ember Vale retains its separate cloud hero: experience earned in
2331
2331
  solo play still belongs to that hero, whose level is sent through the usual command.
2332
+
2333
+ ### Rules input history trailer (studio 0.47.1)
2334
+
2335
+ A rules step keeps its `[tickOffset, ...fields, ...ownerClaim]` prefix. Current
2336
+ views append `[-471, sequence, changeCount, ...changes]`, where each change is
2337
+ `[fieldIndex, changeSequence, fractionOfTick, value]`. All components are numbers.
2338
+ Input frames repeat recent entries, sorted by tick; the retained window is
2339
+ 4–64 ticks based on measured authority RTT and jitter. The host deduplicates
2340
+ entry sequences, including history received after a missing frame. Duplicate
2341
+ history is not a fresh lead observation. Frames without the trailer keep the
2342
+ legacy input semantics.
2343
+
2344
+ Control rows keep their first six columns and append
2345
+ `[471, sequence, appliedTick, ...]`. Positive sequences identify input entries;
2346
+ negative sequences identify the independently numbered reliable pulse stream.
2347
+ The last 64 receipts repeat in snapshots, including projected Gate snapshots.
2348
+ An on-time entry names its predicted tick; a safely rewound movement entry names
2349
+ that historical tick; an entry beyond retained movement history or an authority
2350
+ boundary names the next tick. Entry receipts describe coalesced per-tick input;
2351
+ command/effect handlers are not rerun in the past.
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.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",
@@ -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][]][];
@@ -155,6 +158,7 @@ export interface Core {
155
158
  readonly tick: number;
156
159
  readonly epoch: number;
157
160
  step(inputs?: ReadonlyMap<number, StepInput>, guides?: () => void): void;
161
+ reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean;
158
162
  guide(seat: number, floor?: unknown): Record<string, unknown> | null;
159
163
  vocabulary(value: Vocabulary | null): void;
160
164
  goal(seat: number, value?: unknown): unknown;
@@ -174,10 +178,11 @@ export interface Core {
174
178
  listeners(): { seat: number; id: string; pos: Vec3 }[];
175
179
  /** The body in a seat, as the host runtime needs it for the input protocol. */
176
180
  character(seat: number): import('./rules.ts').Character | null;
177
- bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
181
+ bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; controlled: boolean; driver: Driver } | null;
178
182
  /** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
179
183
  drain(): CoreOut[];
180
184
  save(): SavedCore;
185
+ dispose(): void;
181
186
  /** The `world` object rules are handed (a test walks it to prove nothing else is reachable). */
182
187
  readonly world: object;
183
188
  /**
@@ -195,6 +200,8 @@ export interface Core {
195
200
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
196
201
  */
197
202
  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 {
203
+ if (opts.restore && (c.map.physics !== undefined) !== (opts.restore.physics !== undefined)) throw new Error('physics: save and game differ');
204
+ const physics = physicsWorld(c.map.physics, opts.restore?.physics);
198
205
  let currentMap=c.map;
199
206
  let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
200
207
  let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
@@ -553,6 +560,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
553
560
  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
561
  // 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
562
  unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
563
+ ...(physics ? {physics: () => physics.api} : {}),
556
564
  stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
557
565
  };
558
566
  for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
@@ -824,7 +832,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
824
832
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
825
833
  const moveCtx = brand({} as Record<string, unknown>);
826
834
  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); } });
835
+ Object.assign(moveCtx, { substeps: substeps(c.settings.tickHz), ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
836
+ if (physics) Object.assign(moveCtx, {physics: physics.movement});
828
837
  Object.freeze(moveCtx);
829
838
  const joinCtx = brand({} as Record<string, unknown>);
830
839
  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 });
@@ -1083,7 +1092,46 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1083
1092
  // Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
1084
1093
  return (dx * a.dir.x + dy * a.dir.y + dz * a.dir.z) / l >= exact.cos(a.half);
1085
1094
  }
1095
+ // Movement-only rewind never repeats effects or commands. External body writes fence history.
1096
+ type MovementState = {pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>};
1097
+ type MovementFrame = {tick: number; r: number; before: MovementState; after: MovementState; input: Readonly<Record<string, unknown>>; geometry: typeof moveGeometry; map: typeof currentMap};
1098
+ const movementHistory = new Map<string, MovementFrame[]>();
1099
+ const movementState = (e: Ent): MovementState => ({pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:{...e.m}});
1100
+ const sameMovement = (a: MovementState,b: MovementState): boolean => JSON.stringify(a) === JSON.stringify(b);
1101
+ function putMovement(e: Ent, b: MovementState): void {
1102
+ e.pos=b.pos;e.vel=b.vel;e.heading=b.heading;e.grounded=b.grounded;
1103
+ Object.assign(e.m,b.motion);
1104
+ }
1105
+ function reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean {
1106
+ const id=seats.get(seat)?.id, e=id?ents.get(id):null;
1107
+ if(!e || !e.kind.move || e.kind.body?.owner || e.away || e.driver!=='person') return false;
1108
+ const frames=movementHistory.get(e.id), start=frames?.findIndex(f=>f.tick===at)??-1;
1109
+ if(!frames || start<0 || frames.at(-1)!.tick!==tick || frames[start].r!==e.r || !sameMovement(frames.at(-1)!.after,movementState(e)))return false;
1110
+ const control=e.kind.player?.control;
1111
+ if(control && frames.slice(start).some(f=>!f.before.motion[control] || !f.after.motion[control]))return false;
1112
+ for(let i=start+1;i<frames.length;i++)if(frames[i].r!==e.r || !sameMovement(frames[i-1].after,frames[i].before))return false;
1113
+ const saved=movementState(e), savedTick=tick, geometry=moveGeometry, map=currentMap;
1114
+ const old=frames.map(f=>({...f}));
1115
+ try {
1116
+ putMovement(e,frames[start].before);
1117
+ for(let i=start;i<frames.length;i++){
1118
+ const f=frames[i];tick=f.tick;moveGeometry=f.geometry;currentMap=f.map;moveShape=e.kind.body!;
1119
+ f.before=movementState(e);
1120
+ // An entry holds until the next explicitly different input.
1121
+ if(i===start || JSON.stringify(old[i].input)===JSON.stringify(old[i-1].input))f.input=Object.freeze(coerceFields(e.kind.input,values,dims));else values={...f.input};
1122
+ const b={pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:e.mself};
1123
+ const errors=stats.errors, stops=stats.budgetStops;
1124
+ if(!run(e.kind.name,'move',e,'move',()=>e.kind.move!(b,f.input,moveCtx),'body'))throw new Error('rewind budget');
1125
+ if(stats.errors!==errors || stats.budgetStops!==stops)throw new Error('rewind failed');
1126
+ e.pos=clampIn(V(own(b,'pos')),e.kind.body!.radius,e.kind.body!.height||2*e.kind.body!.radius);e.vel=V(own(b,'vel'));e.heading=dir(own(b,'heading'),dims);e.grounded=own(b,'grounded')===true;
1127
+ f.after=movementState(e);
1128
+ }
1129
+ return true;
1130
+ } catch {putMovement(e,saved);movementHistory.set(e.id,old);return false;}
1131
+ finally {tick=savedTick;moveGeometry=geometry;currentMap=map;}
1132
+ }
1086
1133
  function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
1134
+ for(const id of movementHistory.keys())if(!ents.has(id))movementHistory.delete(id);
1087
1135
  tick = (tick + 1) >>> 0;
1088
1136
  if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
1089
1137
  // The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
@@ -1197,6 +1245,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1197
1245
  }
1198
1246
  if (!k.move) continue;
1199
1247
  const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
1248
+ const rewindBefore = k.player && e.driver === 'person' && !e.away ? movementState(e) : null;
1200
1249
  const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
1201
1250
  moveShape = body;
1202
1251
  run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
@@ -1207,6 +1256,11 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1207
1256
  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();
1208
1257
  G.note = null;
1209
1258
  e.grounded = own(b, 'grounded') === true;
1259
+ if (rewindBefore) {
1260
+ let frames=movementHistory.get(e.id);if(!frames){frames=[];movementHistory.set(e.id,frames);}
1261
+ frames.push({tick,r:e.r,before:rewindBefore,after:movementState(e),input:e.input,geometry:moveGeometry,map:currentMap});
1262
+ while(frames.length>tickHz)frames.shift();
1263
+ }
1210
1264
  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, e.id);
1211
1265
  }
1212
1266
  // Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
@@ -1281,6 +1335,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1281
1335
  }
1282
1336
  function save(): SavedCore {
1283
1337
  return {
1338
+ ...(physics ? {physics: physics.save()} : {}),
1284
1339
  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
1340
  ...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1286
1341
  ...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
@@ -1307,7 +1362,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1307
1362
  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
1363
  }
1309
1364
  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)));
1365
+ 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
1366
  const askNames = new Set();
1312
1367
  check(Array.isArray(r.asks));
1313
1368
  for (const a of r.asks) {
@@ -1429,6 +1484,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1429
1484
  }
1430
1485
  }
1431
1486
  const r = opts.restore;
1487
+ try {
1432
1488
  if (r) {
1433
1489
  if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
1434
1490
  validateSave(r);
@@ -1466,6 +1522,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1466
1522
  ops = r.ops as typeof ops;
1467
1523
  if(hasColliders||mapRevision||pendingMap)collisionState();
1468
1524
  } else startMatch();
1525
+ } catch (error) { physics?.dispose(); throw error; }
1469
1526
 
1470
1527
  function decisionResult(value: unknown): Record<string, unknown> {
1471
1528
  const v = plainData(value, { n: ANSWER_MAX });
@@ -1481,6 +1538,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1481
1538
  return {
1482
1539
  round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
1483
1540
  get tick() { return tick; },
1541
+ reviseMovement,
1484
1542
  get epoch() { return epoch; },
1485
1543
  step,
1486
1544
  answer: (n, result) => { const a = pendingAsks.find((x) => x.n === n); if (a && !a.result) a.result = decisionResult(result); },
@@ -1557,9 +1615,10 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1557
1615
  character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
1558
1616
  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 })),
1559
1617
  listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
1560
- 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; },
1618
+ 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, controlled: !e.kind.player?.control || Boolean(e.m[e.kind.player.control]), driver: e.driver } : null; },
1561
1619
  drain: () => { const o = out; out = []; return o; },
1562
1620
  save,
1621
+ dispose: () => physics?.dispose(),
1563
1622
  world,
1564
1623
  stats,
1565
1624
  };
package/rules/host.ts CHANGED
@@ -135,8 +135,11 @@ export interface Host {
135
135
  readonly core: Core;
136
136
  }
137
137
 
138
- interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
138
+ interface Entry { placement?: number; source?: number; at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
139
139
  interface SeatIn {
140
+ heldAt?: number;
141
+ receipts?: [number, number][];
142
+ received?: number[];
140
143
  /** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
141
144
  pulses?: [string,number,number?,number?][];
142
145
  /** The wait of the entry now held, in ticks (see Entry.early). */
@@ -259,9 +262,11 @@ export function createHost(o: HostOptions): Host {
259
262
  check(Array.isArray(saved.inputs) && saved.inputs.length <= c.seats);
260
263
  for (const [seat, q] of saved.inputs) {
261
264
  check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
265
+ if(q.received!==undefined)check(Array.isArray(q.received)&&q.received.length<=256&&q.received.every(uint));
266
+ if(q.receipts!==undefined)check(Array.isArray(q.receipts)&&q.receipts.length<=64&&q.receipts.every(r=>Array.isArray(r)&&r.length===2&&Number.isSafeInteger(r[0])&&r[0]!==0&&uint(r[1])));
262
267
  check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
263
268
  if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
264
- for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
269
+ for (const e of q.entries) check((e.placement === undefined || uint(e.placement)) && uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
265
270
  }
266
271
  }
267
272
  }
@@ -474,8 +479,8 @@ export function createHost(o: HostOptions): Host {
474
479
  stats.ins += 1;
475
480
  q.lastFrameTick = K;
476
481
  // Lead: from this frame's arrival to the moment its first tick was due, in sixteenths of a tick. Negative is late.
477
- const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k - base.tick) * period - o.clock.now()) / period) * 16)));
478
- if (lead < q.lead) q.lead = lead;
482
+ const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k + num(m.s.at(-1)?.[0]) - base.tick) * period - o.clock.now()) / period) * 16)));
483
+
479
484
  const r = num(m.r) & 0xffff;
480
485
  let before = -1;
481
486
  for (const raw of (m.s as unknown[]).slice(0, 64)) {
@@ -485,10 +490,18 @@ export function createHost(o: HostOptions): Host {
485
490
  before = off;
486
491
  const j = (k + off) >>> 0;
487
492
  // A duplicate: not above the newest stamp this seat has sent. It still counted as the player being there.
488
- if (j <= q.newest) continue;
493
+ const marker = 1 + kind.input.length + (kind.body?.owner ? 9 : 0);
494
+ const seq = raw[marker] === -471 && Number.isSafeInteger(raw[marker+1]) && raw[marker+1] > 0 ? raw[marker+1] as number : 0;
495
+ // A discrete action sent before the client learns of a placement still
496
+ // belongs to its player. Held state from that old placement does not.
497
+ const action = kind.input.some(([, fd], index) => fd.t === 'press' && raw[index + 1] === 1);
498
+ if (seq && r !== body.r && !action) continue;
499
+ if (seq ? q.received?.includes(seq) : j <= q.newest) continue;
489
500
  // Too early: more than a second ahead. This entry and the rest of the frame are dropped.
490
501
  if (j > K + tickHz) { stats.dropped += 1; break; }
491
- q.newest = j;
502
+ if(j >= q.newest && lead < q.lead)q.lead=lead;
503
+ q.newest = Math.max(q.newest,j);
504
+ if(seq){q.received??=[];q.received.push(seq);if(q.received.length>256)q.received.shift();}
492
505
  const values: Record<string, unknown> = {};
493
506
  const presses: (string | [string, number])[] = [];
494
507
  let i = 1;
@@ -499,9 +512,22 @@ export function createHost(o: HostOptions): Host {
499
512
  }
500
513
  const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
501
514
  q.stamps.push(j);
502
- if (j > K) { q.entries.push({ at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
515
+ if(seq){q.receipts??=[];q.receipts.push([seq,j>K?j:K+1]);if(q.receipts.length>64)q.receipts.shift();}
516
+ if (j > K) { q.entries.push({ ...(seq ? { placement: body.r } : {}), source: j, at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
503
517
  // Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
504
518
  stats.lateEntries += 1;
519
+ if(seq){
520
+ if(!presses.length && K-j<tickHz && core.reviseMovement(seat as number,j,values))q.receipts![q.receipts!.length-1][1]=j;
521
+ const at=K+1;
522
+ const same=q.entries.find(e=>e.at===at);
523
+ if(j >= (q.heldAt??0) && (!same || j >= (same.source??0))){
524
+ if(same){same.placement=body.r;same.source=j;same.values=values;same.claim=claim;same.presses.push(...presses);}else q.entries.push({placement:body.r,source:j,at,values,claim,presses});
525
+ } else if(presses.length){
526
+ if(same)same.presses.push(...presses);else q.entries.push({placement:body.r,source:q.heldAt,at,values:{...q.held},claim:q.claim,presses});
527
+ }
528
+ q.entries.sort((a,b)=>a.at-b.at);
529
+ continue;
530
+ }
505
531
  const book = heldBook.get(seat as number);
506
532
  if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
507
533
  const at = (K + 1) >>> 0;
@@ -704,7 +730,8 @@ export function createHost(o: HostOptions): Host {
704
730
  const presses: (string | [string, number])[] = [];
705
731
  while (q.entries.length && q.entries[0].at <= t) {
706
732
  const e = q.entries.shift() as Entry;
707
- q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
733
+ if (e.placement !== undefined && e.placement !== body.r) continue;
734
+ q.heldAt=e.source??e.at; q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
708
735
  if (e.at === t) for (const p of e.presses) presses.push(p);
709
736
  }
710
737
  // Silence: no frame for a second. Its input is neutral until the next entry.
@@ -723,7 +750,7 @@ export function createHost(o: HostOptions): Host {
723
750
  }
724
751
  for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
725
752
  // One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
726
- const head=q.pulses?.[0];if(head&&!((head[2]??0)>t&&(head[2] as number)-t<=Math.ceil(tickHz/4))){q.pulses!.shift();values[head[0]]=head[1];if(head[3])pulsesTaken.set(seat,head[3]);}
753
+ const head=q.pulses?.[0];if(head&&(body.controlled||body.kind.player?.pulses!=='hold')&&(head[2]??0)<=t){q.pulses!.shift();values[head[0]]=head[1];if(head[3]){pulsesTaken.set(seat,head[3]);q.receipts??=[];q.receipts.push([-head[3],t]);if(q.receipts.length>64)q.receipts.shift();}}
727
754
  inputs.set(seat, { values, claim: q.claim });
728
755
  }
729
756
  if (roundRebasePending) publishRebase(now);
@@ -760,6 +787,7 @@ export function createHost(o: HostOptions): Host {
760
787
  // ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
761
788
  if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
762
789
  else if (taken !== undefined) row.push(taken);
790
+ if(q?.receipts){while(row.length<6)row.push(0);row.push(471,...q.receipts.flat());}
763
791
  rows.push(row);
764
792
  if (q) q.lead = 127;
765
793
  }
@@ -932,6 +960,7 @@ export function createHost(o: HostOptions): Host {
932
960
  }
933
961
  function stop(): void {
934
962
  running = false; paused = false; ended = true;
963
+ core.dispose();
935
964
  disarm();
936
965
  agents?.stop();
937
966
  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
+ }