@irtio/runtime 0.7.0 → 0.9.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.
@@ -250,7 +250,7 @@ function createCallProxy(core, clientId) {
250
250
  }
251
251
  );
252
252
  }
253
- function createBroadcastProxy(core) {
253
+ function createBroadcastProxy(core, except) {
254
254
  return new Proxy(
255
255
  {},
256
256
  {
@@ -259,13 +259,19 @@ function createBroadcastProxy(core) {
259
259
  return (params) => {
260
260
  const desc = descFor(core, prop);
261
261
  for (const entry of core.clients.values()) {
262
- if (entry.connected) sendCall(core, entry.clientId, desc, params ?? {});
262
+ if (!entry.connected) continue;
263
+ if (except?.has(entry.clientId)) continue;
264
+ sendCall(core, entry.clientId, desc, params ?? {});
263
265
  }
264
266
  };
265
267
  }
266
268
  }
267
269
  );
268
270
  }
271
+ function createBroadcastExceptProxy(core, except) {
272
+ const ids = typeof except === "string" ? [except] : except;
273
+ return createBroadcastProxy(core, new Set(ids));
274
+ }
269
275
  function handleReply(core, clientId, payload) {
270
276
  let reply;
271
277
  try {
@@ -956,6 +962,7 @@ function decodeMatterBodies(bytes) {
956
962
  function bodyKey(collection, id) {
957
963
  return `${collection}\0${id}`;
958
964
  }
965
+ var PLANAR_CHANNELS = /* @__PURE__ */ new Set(["x", "y", "qz", "qw", "vx", "vy", "wz"]);
959
966
  var MatterRuntime = class {
960
967
  engineKind = "matter2d";
961
968
  /** rapier3d only; present so both runtimes satisfy one internal shape. */
@@ -981,6 +988,15 @@ var MatterRuntime = class {
981
988
  restore;
982
989
  stepMs;
983
990
  sleepSynced = /* @__PURE__ */ new Set();
991
+ /**
992
+ * The plain record object each body was built from. See `PhysicsRuntime.sourceRecords`: `add()`
993
+ * installs a new record object, so an id removed and re-added between two reconciles (a pooled
994
+ * projectile) is identifiable by identity, and is the one case where a live body has to be
995
+ * rebuilt so the new row's spawn pose and velocity are honoured.
996
+ */
997
+ sourceRecords = /* @__PURE__ */ new Map();
998
+ /** `collection.field` pairs already warned about a channel a plane cannot hold. */
999
+ warnedChannels = /* @__PURE__ */ new Set();
984
1000
  constructor(core, matter, options) {
985
1001
  const config = core.definition.config.physics;
986
1002
  if (!config) throw new Error("MatterRuntime: the room config declares no physics");
@@ -1050,6 +1066,7 @@ var MatterRuntime = class {
1050
1066
  } else {
1051
1067
  this.applyRecordToBody(desc, spec.body, record);
1052
1068
  }
1069
+ this.sourceRecords.set(key, record);
1053
1070
  return spec.body;
1054
1071
  }
1055
1072
  // ---- M6 lane F: rewind ----
@@ -1089,6 +1106,17 @@ var MatterRuntime = class {
1089
1106
  for (const [channel, field] of physics.channels) {
1090
1107
  const raw = record[field];
1091
1108
  if (typeof raw !== "number") continue;
1109
+ if (raw !== 0 && !PLANAR_CHANNELS.has(channel)) {
1110
+ const key = `${desc.name}.${field}`;
1111
+ if (!this.warnedChannels.has(key)) {
1112
+ this.warnedChannels.add(key);
1113
+ this.core.log(
1114
+ "warn",
1115
+ `irtio: ${desc.name}.${field} maps body channel ${channel}, which a matter2d room cannot hold (the world is a plane); the ${raw} it was added with was dropped`
1116
+ );
1117
+ }
1118
+ continue;
1119
+ }
1092
1120
  applyChannel2d(channel, raw, t);
1093
1121
  }
1094
1122
  this.applyState(body, {
@@ -1110,8 +1138,21 @@ var MatterRuntime = class {
1110
1138
  for (const id of coll.ids()) {
1111
1139
  const key = bodyKey(desc.name, id);
1112
1140
  live.add(key);
1113
- if (this.bodies.has(key)) continue;
1114
1141
  const record = coll.get(id);
1142
+ const existing = this.bodies.get(key);
1143
+ if (existing) {
1144
+ if (record === void 0) continue;
1145
+ const source = this.sourceRecords.get(key);
1146
+ if (source === void 0) {
1147
+ this.sourceRecords.set(key, record);
1148
+ continue;
1149
+ }
1150
+ if (source === record) continue;
1151
+ this.bodies.delete(key);
1152
+ this.sleepSynced.delete(key);
1153
+ this.sourceRecords.delete(key);
1154
+ this.matter.Composite.remove(this.engine.world, [existing.body, ...existing.constraints]);
1155
+ }
1115
1156
  if (record !== void 0) this.create(desc, id, record);
1116
1157
  }
1117
1158
  }
@@ -1119,6 +1160,7 @@ var MatterRuntime = class {
1119
1160
  if (live.has(key)) continue;
1120
1161
  this.bodies.delete(key);
1121
1162
  this.sleepSynced.delete(key);
1163
+ this.sourceRecords.delete(key);
1122
1164
  this.matter.Composite.remove(this.engine.world, [attached.body, ...attached.constraints]);
1123
1165
  }
1124
1166
  }
@@ -1255,9 +1297,28 @@ function encodePhysicsSection(section) {
1255
1297
  return w.finish();
1256
1298
  }
1257
1299
  var MATTER_TAG = 1;
1300
+ var RAPIER2D_TAG = 2;
1258
1301
  function physicsSectionEngine(bytes) {
1259
1302
  const r = new ByteReader2(bytes);
1260
- return r.varint() === 0 && r.u8() === MATTER_TAG ? "matter2d" : "rapier3d";
1303
+ if (r.varint() !== 0) return "rapier3d";
1304
+ const tag = r.u8();
1305
+ if (tag === MATTER_TAG) return "matter2d";
1306
+ if (tag === RAPIER2D_TAG) return "rapier2d";
1307
+ return "rapier3d";
1308
+ }
1309
+ function encodeRapier2dSectionEnvelope(payload) {
1310
+ const w = new ByteWriter2(payload.length + 8);
1311
+ w.varint(0);
1312
+ w.u8(RAPIER2D_TAG);
1313
+ w.bytes(payload);
1314
+ return w.finish();
1315
+ }
1316
+ function decodeRapier2dSectionEnvelope(bytes) {
1317
+ const r = new ByteReader2(bytes);
1318
+ if (r.varint() !== 0 || r.u8() !== RAPIER2D_TAG) {
1319
+ throw new Error("irtio: this physics section was not written by rapier2d");
1320
+ }
1321
+ return r.rest();
1261
1322
  }
1262
1323
  function encodeMatterSectionEnvelope(payload) {
1263
1324
  const w = new ByteWriter2(payload.length + 8);
@@ -1330,6 +1391,19 @@ var PhysicsRuntime = class {
1330
1391
  * world rebuilt from schema state would wake it with a kick). Found by the drift check.
1331
1392
  */
1332
1393
  sleepSynced = /* @__PURE__ */ new Set();
1394
+ /**
1395
+ * The plain record object each body was built from, by body key. `add()` always installs a
1396
+ * **new** record object (`normalizeRecord` builds one), so an id that is removed and re-added
1397
+ * between two reconciles — the pooled-projectile shape — is identifiable by identity alone,
1398
+ * which is the only way to see it from here: both edits happened before this module looked.
1399
+ * Without it the old body kept its pose and velocity and the new row's spawn values were lost.
1400
+ *
1401
+ * Unknown means adopt, never rebuild: a world restored from a hibernation blob has bodies with
1402
+ * no recorded source, and rebuilding those would throw away exactly what the blob preserved.
1403
+ */
1404
+ sourceRecords = /* @__PURE__ */ new Map();
1405
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1406
+ warnedChannels = /* @__PURE__ */ new Set();
1333
1407
  constructor(core, rapier, options) {
1334
1408
  const config = core.definition.config.physics;
1335
1409
  if (!config) throw new Error("PhysicsRuntime: the room config declares no physics");
@@ -1404,10 +1478,50 @@ var PhysicsRuntime = class {
1404
1478
  }
1405
1479
  const body = this.world.createRigidBody(spec.body);
1406
1480
  for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1481
+ this.warnLockedChannels(desc, spec, record);
1407
1482
  this.applyRecordToBody(desc, body, record);
1408
1483
  this.bodies.set(bodyKey2(desc.name, id), body);
1484
+ this.sourceRecords.set(bodyKey2(desc.name, id), record);
1409
1485
  return body;
1410
1486
  }
1487
+ /**
1488
+ * A spawn value the engine will not take, said once per field.
1489
+ *
1490
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
1491
+ * accepts one on a fixed body and then ignores it — both read back as "the field was applied"
1492
+ * and behave as if it never was. The desc says which, before the body exists, so the check is
1493
+ * on the desc: the usual case is the 2D-in-3D recipe (`enabledTranslations(true, true, false)`)
1494
+ * meeting a row that spawns with a `vz`. Named by field, because the field is what the author
1495
+ * wrote and what they will grep for.
1496
+ */
1497
+ warnLockedChannels(desc, spec, record) {
1498
+ const physics = desc.physics;
1499
+ if (!physics) return;
1500
+ const b = spec.body;
1501
+ const frozen = b.status !== 0;
1502
+ const enabled = {
1503
+ vx: b.translationsEnabledX,
1504
+ vy: b.translationsEnabledY,
1505
+ vz: b.translationsEnabledZ,
1506
+ wx: b.rotationsEnabledX,
1507
+ wy: b.rotationsEnabledY,
1508
+ wz: b.rotationsEnabledZ
1509
+ };
1510
+ for (const [channel, field] of physics.channels) {
1511
+ const allowed = enabled[channel];
1512
+ if (allowed === void 0) continue;
1513
+ const raw = record[field];
1514
+ if (typeof raw !== "number" || raw === 0) continue;
1515
+ if (allowed && !frozen) continue;
1516
+ const key = `${desc.name}.${field}`;
1517
+ if (this.warnedChannels.has(key)) continue;
1518
+ this.warnedChannels.add(key);
1519
+ this.core.log(
1520
+ "warn",
1521
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
1522
+ );
1523
+ }
1524
+ }
1411
1525
  // ---- M6 lane F: rewind ----
1412
1526
  /**
1413
1527
  * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
@@ -1503,8 +1617,21 @@ var PhysicsRuntime = class {
1503
1617
  for (const id of coll.ids()) {
1504
1618
  const key = bodyKey2(desc.name, id);
1505
1619
  live.add(key);
1506
- if (this.bodies.has(key)) continue;
1507
1620
  const record = coll.get(id);
1621
+ const existing = this.bodies.get(key);
1622
+ if (existing) {
1623
+ if (record === void 0) continue;
1624
+ const source = this.sourceRecords.get(key);
1625
+ if (source === void 0) {
1626
+ this.sourceRecords.set(key, record);
1627
+ continue;
1628
+ }
1629
+ if (source === record) continue;
1630
+ this.bodies.delete(key);
1631
+ this.sleepSynced.delete(key);
1632
+ this.sourceRecords.delete(key);
1633
+ this.world.removeRigidBody(existing);
1634
+ }
1508
1635
  if (record !== void 0) this.create(desc, id, record);
1509
1636
  }
1510
1637
  }
@@ -1512,6 +1639,7 @@ var PhysicsRuntime = class {
1512
1639
  if (live.has(key)) continue;
1513
1640
  this.bodies.delete(key);
1514
1641
  this.sleepSynced.delete(key);
1642
+ this.sourceRecords.delete(key);
1515
1643
  this.world.removeRigidBody(body);
1516
1644
  }
1517
1645
  }
@@ -1611,6 +1739,14 @@ function channelValue(channel, t, r, v, w) {
1611
1739
  }
1612
1740
 
1613
1741
  // src/core/random.ts
1742
+ function seedFromRoomId(roomId) {
1743
+ let h = 2166136261;
1744
+ for (let i = 0; i < roomId.length; i++) {
1745
+ h ^= roomId.charCodeAt(i);
1746
+ h = Math.imul(h, 16777619) >>> 0;
1747
+ }
1748
+ return h === 0 ? 2654435769 : h >>> 0;
1749
+ }
1614
1750
  var Mulberry32 = class {
1615
1751
  /** Current internal state (u32). Survives hibernation. */
1616
1752
  state;
@@ -1627,6 +1763,324 @@ var Mulberry32 = class {
1627
1763
  }
1628
1764
  };
1629
1765
 
1766
+ // src/core/rapier2d.ts
1767
+ import { angleFrom2d, applyChannel2d as applyChannel2d2, channelOf2d as channelOf2d2 } from "@irtio/schema";
1768
+ var engine3;
1769
+ var loading3;
1770
+ async function initRapier2d() {
1771
+ if (engine3) return engine3;
1772
+ loading3 ??= (async () => {
1773
+ const mod = await import("@dimforge/rapier2d-compat");
1774
+ const ns = mod.default ?? mod;
1775
+ await ns.init();
1776
+ engine3 = ns;
1777
+ return ns;
1778
+ })();
1779
+ return loading3;
1780
+ }
1781
+ function loadedRapier2d() {
1782
+ return engine3;
1783
+ }
1784
+ function resetRapier2dForTests() {
1785
+ engine3 = void 0;
1786
+ loading3 = void 0;
1787
+ }
1788
+ function bodyKey3(collection, id) {
1789
+ return `${collection}\0${id}`;
1790
+ }
1791
+ var Rapier2dRuntime = class {
1792
+ engineKind = "rapier2d";
1793
+ /** matter2d only; present so every runtime satisfies one internal shape. */
1794
+ matter = void 0;
1795
+ matterEngine = void 0;
1796
+ rapier;
1797
+ world;
1798
+ /** `true` when the world was built from scratch and `setup` has to run. */
1799
+ rebuilt;
1800
+ /** Rapier restores a whole world, contacts included, so `setup` runs only on a rebuild. */
1801
+ get needsSetup() {
1802
+ return this.rebuilt;
1803
+ }
1804
+ core;
1805
+ config;
1806
+ /** Physics-backed collections, in schema (name-sorted) order. */
1807
+ collections;
1808
+ bodies = /* @__PURE__ */ new Map();
1809
+ /** See `PhysicsRuntime.sleepSynced`: the one extra sync a body owes on the tick it sleeps. */
1810
+ sleepSynced = /* @__PURE__ */ new Set();
1811
+ /**
1812
+ * See `PhysicsRuntime.sourceRecords`: the plain record object each body was built from. `add()`
1813
+ * always installs a new record object, so an id removed and re-added between two reconciles —
1814
+ * the pooled-projectile shape — is identifiable by identity alone, and its body is rebuilt so
1815
+ * the new row's spawn pose and velocity are what the world gets. Unknown means adopt, never
1816
+ * rebuild: a world restored from a hibernation blob has bodies with no recorded source.
1817
+ */
1818
+ sourceRecords = /* @__PURE__ */ new Map();
1819
+ /** `collection.field` pairs already warned about a channel value the engine would not take. */
1820
+ warnedChannels = /* @__PURE__ */ new Set();
1821
+ constructor(core, rapier, options) {
1822
+ const config = core.definition.config.physics;
1823
+ if (!config) throw new Error("Rapier2dRuntime: the room config declares no physics");
1824
+ this.core = core;
1825
+ this.config = config;
1826
+ this.rapier = rapier;
1827
+ this.collections = core.ext.collections.filter(
1828
+ (c) => c.physics !== void 0
1829
+ );
1830
+ if (options.restore) {
1831
+ this.world = rapier.World.restoreSnapshot(options.restore.world);
1832
+ this.rebuilt = false;
1833
+ for (const [name, id, handle] of options.restore.bodies) {
1834
+ const body = this.world.getRigidBody(handle);
1835
+ if (body) this.bodies.set(bodyKey3(name, id), body);
1836
+ }
1837
+ } else {
1838
+ const g = config.gravity;
1839
+ this.world = new rapier.World({ x: g.x, y: g.y });
1840
+ this.rebuilt = true;
1841
+ }
1842
+ this.world.timestep = config.timestep ?? options.defaultTimestep;
1843
+ }
1844
+ get timestep() {
1845
+ return this.world.timestep;
1846
+ }
1847
+ /** Runs the room's `setup` — static geometry — on a world that was built rather than restored. */
1848
+ runSetup(room) {
1849
+ const setup = this.config.setup;
1850
+ if (!setup) return;
1851
+ this.core.guard("physics.setup", () => setup(this.world, this.rapier, room));
1852
+ }
1853
+ free() {
1854
+ this.bodies.clear();
1855
+ this.world.free();
1856
+ }
1857
+ // -------------------------------------------------------------------------
1858
+ // Bodies
1859
+ // -------------------------------------------------------------------------
1860
+ /** The body behind an instance, created on demand so a handler's own `add` is usable at once. */
1861
+ bodyFor(collection, id) {
1862
+ const existing = this.bodies.get(bodyKey3(collection, id));
1863
+ if (existing) return existing;
1864
+ const desc = this.collections.find((c) => c.name === collection);
1865
+ if (!desc) return void 0;
1866
+ const coll = plainEntity(this.core.plain, collection);
1867
+ const record = coll.get(id);
1868
+ if (record === void 0) return void 0;
1869
+ return this.create(desc, id, record);
1870
+ }
1871
+ create(desc, id, record) {
1872
+ const factory = this.config.bodies?.[desc.name];
1873
+ if (!factory) {
1874
+ this.core.log("error", `irtio: physics.bodies.${desc.name} is missing; no body created`);
1875
+ return void 0;
1876
+ }
1877
+ const spec = this.core.guard(
1878
+ `physics.bodies.${desc.name}`,
1879
+ () => factory(this.rapier, record, id)
1880
+ );
1881
+ if (!spec || !spec.body) {
1882
+ this.core.log(
1883
+ "error",
1884
+ `irtio: physics.bodies.${desc.name} returned no { body } for ${JSON.stringify(id)}`
1885
+ );
1886
+ return void 0;
1887
+ }
1888
+ const body = this.world.createRigidBody(spec.body);
1889
+ for (const collider of spec.colliders ?? []) this.world.createCollider(collider, body);
1890
+ this.warnLockedChannels(desc, spec, record);
1891
+ this.applyRecordToBody(desc, body, record);
1892
+ this.bodies.set(bodyKey3(desc.name, id), body);
1893
+ this.sourceRecords.set(bodyKey3(desc.name, id), record);
1894
+ return body;
1895
+ }
1896
+ /**
1897
+ * A spawn value the engine will not take, said once per field — the planar half of
1898
+ * `PhysicsRuntime.warnLockedChannels`.
1899
+ *
1900
+ * Rapier accepts `setLinvel` on an axis the body has disabled and then keeps zero, and it
1901
+ * accepts one on a fixed body and then ignores it: both read back as "the field was applied"
1902
+ * and behave as if it never was. The desc says which, before the body exists, so the check is on
1903
+ * the desc. In the plane there are three movable channels — `vx`, `vy` and the one rotation
1904
+ * `wz` — and the desc carries exactly those three flags. Named by field, because the field is
1905
+ * what the author wrote and what they will grep for.
1906
+ */
1907
+ warnLockedChannels(desc, spec, record) {
1908
+ const physics = desc.physics;
1909
+ if (!physics) return;
1910
+ const b = spec.body;
1911
+ const frozen = b.status !== 0;
1912
+ const enabled = {
1913
+ vx: b.translationsEnabledX,
1914
+ vy: b.translationsEnabledY,
1915
+ wz: b.rotationsEnabled
1916
+ };
1917
+ for (const [channel, field] of physics.channels) {
1918
+ const allowed = enabled[channel];
1919
+ if (allowed === void 0) continue;
1920
+ const raw = record[field];
1921
+ if (typeof raw !== "number" || raw === 0) continue;
1922
+ if (allowed && !frozen) continue;
1923
+ const key = `${desc.name}.${field}`;
1924
+ if (this.warnedChannels.has(key)) continue;
1925
+ this.warnedChannels.add(key);
1926
+ this.core.log(
1927
+ "warn",
1928
+ `irtio: ${desc.name}.${field} (body channel ${channel}) was added as ${raw}, but ` + (frozen ? `physics.bodies.${desc.name} builds a non-dynamic body, which never moves under it` : `that degree of freedom is disabled by physics.bodies.${desc.name}`) + " \u2014 the value was dropped"
1929
+ );
1930
+ }
1931
+ }
1932
+ // ---- M6 lane F: rewind ----
1933
+ /**
1934
+ * D72: every tracked body, in the order `sync()` walks them (collection order, then instance
1935
+ * order). The rapier2d third of the same read-only accessor the other two runtimes carry.
1936
+ */
1937
+ eachTrackedBody(fn) {
1938
+ for (const desc of this.collections) {
1939
+ const plainColl = plainEntity(this.core.plain, desc.name);
1940
+ for (const id of plainColl.ids()) {
1941
+ const body = this.bodies.get(bodyKey3(desc.name, id));
1942
+ if (body) fn(desc.name, id, body);
1943
+ }
1944
+ }
1945
+ }
1946
+ applyRecordToBody(desc, body, record) {
1947
+ const physics = desc.physics;
1948
+ if (!physics) return;
1949
+ const t = body.translation();
1950
+ const v = body.linvel();
1951
+ const angle = body.rotation();
1952
+ const target = {
1953
+ x: t.x,
1954
+ y: t.y,
1955
+ qz: Math.sin(angle / 2),
1956
+ qw: Math.cos(angle / 2),
1957
+ vx: v.x,
1958
+ vy: v.y,
1959
+ wz: body.angvel()
1960
+ };
1961
+ for (const [channel, field] of physics.channels) {
1962
+ const raw = record[field];
1963
+ if (typeof raw !== "number") continue;
1964
+ applyChannel2d2(channel, raw, target);
1965
+ }
1966
+ body.setTranslation({ x: target.x, y: target.y }, true);
1967
+ body.setRotation(angleFrom2d(target.qz, target.qw), true);
1968
+ body.setLinvel({ x: target.vx, y: target.vy }, true);
1969
+ body.setAngvel(target.wz, true);
1970
+ }
1971
+ /**
1972
+ * Creates bodies for new instances and destroys bodies whose instance is gone. Runs once per
1973
+ * tick, right before the step, in collection order then instance order.
1974
+ */
1975
+ reconcile() {
1976
+ const live = /* @__PURE__ */ new Set();
1977
+ for (const desc of this.collections) {
1978
+ const coll = plainEntity(this.core.plain, desc.name);
1979
+ for (const id of coll.ids()) {
1980
+ const key = bodyKey3(desc.name, id);
1981
+ live.add(key);
1982
+ const record = coll.get(id);
1983
+ const existing = this.bodies.get(key);
1984
+ if (existing) {
1985
+ if (record === void 0) continue;
1986
+ const source = this.sourceRecords.get(key);
1987
+ if (source === void 0) {
1988
+ this.sourceRecords.set(key, record);
1989
+ continue;
1990
+ }
1991
+ if (source === record) continue;
1992
+ this.bodies.delete(key);
1993
+ this.sleepSynced.delete(key);
1994
+ this.sourceRecords.delete(key);
1995
+ this.world.removeRigidBody(existing);
1996
+ }
1997
+ if (record !== void 0) this.create(desc, id, record);
1998
+ }
1999
+ }
2000
+ for (const [key, body] of [...this.bodies]) {
2001
+ if (live.has(key)) continue;
2002
+ this.bodies.delete(key);
2003
+ this.sleepSynced.delete(key);
2004
+ this.sourceRecords.delete(key);
2005
+ this.world.removeRigidBody(body);
2006
+ }
2007
+ }
2008
+ // -------------------------------------------------------------------------
2009
+ // Step and sync
2010
+ // -------------------------------------------------------------------------
2011
+ step() {
2012
+ this.world.step();
2013
+ if (!rapierHasStepped()) noteRapierStep();
2014
+ }
2015
+ /**
2016
+ * Body → schema, through the tracked proxies, so movement leaves the room as an ordinary delta.
2017
+ * Values are `Math.fround`ed for f32 fields, so room code reads exactly what the wire carries.
2018
+ */
2019
+ sync() {
2020
+ for (const desc of this.collections) {
2021
+ const physics = desc.physics;
2022
+ if (!physics) continue;
2023
+ const tracked = this.core.anyState[desc.name];
2024
+ const plainColl = plainEntity(this.core.plain, desc.name);
2025
+ const rounders = roundersFor3(desc);
2026
+ for (const id of plainColl.ids()) {
2027
+ const key = bodyKey3(desc.name, id);
2028
+ const body = this.bodies.get(key);
2029
+ if (!body) continue;
2030
+ if (body.isSleeping()) {
2031
+ if (this.sleepSynced.has(key)) continue;
2032
+ this.sleepSynced.add(key);
2033
+ } else {
2034
+ this.sleepSynced.delete(key);
2035
+ }
2036
+ const record = tracked.get(id);
2037
+ if (!record) continue;
2038
+ const t = body.translation();
2039
+ const v = body.linvel();
2040
+ const state = {
2041
+ x: t.x,
2042
+ y: t.y,
2043
+ angle: body.rotation(),
2044
+ vx: v.x,
2045
+ vy: v.y,
2046
+ angularVelocity: body.angvel()
2047
+ };
2048
+ for (const [channel, field] of physics.channels) {
2049
+ const next = (rounders[field] ?? identity3)(channelOf2d2(channel, state));
2050
+ if (record[field] !== next) record[field] = next;
2051
+ }
2052
+ }
2053
+ }
2054
+ }
2055
+ // -------------------------------------------------------------------------
2056
+ // Hibernation
2057
+ // -------------------------------------------------------------------------
2058
+ /**
2059
+ * The same `PhysicsSection` the 3D runtime writes — a real engine snapshot plus the
2060
+ * entity↔handle map, whose handles must ride as f64 for the reason spelled out on
2061
+ * {@link PhysicsSection}. What tells the two apart on the way back in is the envelope
2062
+ * `core/physics.ts` wraps this in, not anything in here.
2063
+ */
2064
+ serialize() {
2065
+ const bodies = [];
2066
+ for (const [key, body] of this.bodies) {
2067
+ const sep = key.indexOf("\0");
2068
+ bodies.push([key.slice(0, sep), key.slice(sep + 1), body.handle]);
2069
+ }
2070
+ return { world: this.world.takeSnapshot(), bodies };
2071
+ }
2072
+ };
2073
+ function identity3(v) {
2074
+ return v;
2075
+ }
2076
+ function roundersFor3(desc) {
2077
+ const out = {};
2078
+ for (const f of desc.fields) {
2079
+ if (f.type.kind === "f32") out[f.name] = Math.fround;
2080
+ }
2081
+ return out;
2082
+ }
2083
+
1630
2084
  // src/core/views.ts
1631
2085
  import {
1632
2086
  collectionDirty,
@@ -1869,8 +2323,8 @@ function catchUpDirty(ext, plain, fromRole, toRole) {
1869
2323
  // src/core/snapshot.ts
1870
2324
  import { withBuiltins } from "@irtio/protocol";
1871
2325
  import { ByteReader as ByteReader3, ByteWriter as ByteWriter3, decodeSnapshot } from "@irtio/schema";
1872
- var SNAPSHOT_FORMAT_VERSION = 2;
1873
- var READABLE_SNAPSHOT_VERSIONS = [1, 2];
2326
+ var SNAPSHOT_FORMAT_VERSION = 3;
2327
+ var READABLE_SNAPSHOT_VERSIONS = [1, 2, 3];
1874
2328
  function parseHibernationBlob(bytes) {
1875
2329
  const r = new ByteReader3(bytes);
1876
2330
  const version = r.u8();
@@ -1881,22 +2335,31 @@ function parseHibernationBlob(bytes) {
1881
2335
  const rngState = r.u32();
1882
2336
  const tick = r.u32();
1883
2337
  const mode = r.u8() === 1 ? "event" : "tick";
2338
+ const schemaHash8 = version >= 3 ? r.bytes(8).slice() : void 0;
1884
2339
  let physics;
1885
2340
  if (version >= 2) {
1886
2341
  const section = r.blob();
1887
2342
  if (section.length > 0) physics = section;
1888
2343
  }
1889
- return { version, seed, rngState, tick, mode, physics, snapshot: r.rest() };
2344
+ return { version, seed, rngState, tick, mode, schemaHash8, physics, snapshot: r.rest() };
2345
+ }
2346
+ function sameSchemaHash(a, b) {
2347
+ if (!a || !b) return true;
2348
+ if (a.length !== b.length) return false;
2349
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
2350
+ return true;
1890
2351
  }
1891
2352
  function writeHibernationBlob(header, snapshot, physics) {
1892
- const version = physics ? 2 : 1;
1893
- const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 24);
2353
+ const hash = header.schemaHash8;
2354
+ const version = hash ? 3 : physics ? 2 : 1;
2355
+ const w = new ByteWriter3(snapshot.length + (physics?.length ?? 0) + 32);
1894
2356
  w.u8(version);
1895
2357
  w.u32(header.seed >>> 0);
1896
2358
  w.u32(header.rngState >>> 0);
1897
2359
  w.u32(header.tick >>> 0);
1898
2360
  w.u8(header.mode === "event" ? 1 : 0);
1899
- if (physics) w.blob(physics);
2361
+ if (hash) w.bytes(hash);
2362
+ if (version >= 2) w.blob(physics ?? new Uint8Array(0));
1900
2363
  w.bytes(snapshot);
1901
2364
  return w.finish();
1902
2365
  }
@@ -2038,9 +2501,10 @@ var PoseHistory = class {
2038
2501
  entry.tick = tick;
2039
2502
  entry.count = 0;
2040
2503
  const pose = this.scratchPose;
2041
- const rapier = physics.engineKind === "rapier3d";
2504
+ const kind = physics.engineKind;
2042
2505
  physics.eachTrackedBody((collection, id, body) => {
2043
- if (rapier) readRapierPose(body, pose);
2506
+ if (kind === "rapier3d") readRapierPose(body, pose);
2507
+ else if (kind === "rapier2d") readRapier2dPose(body, pose);
2044
2508
  else readMatterPose(body, pose);
2045
2509
  this.push(entry, collection, id, pose);
2046
2510
  });
@@ -2142,6 +2606,24 @@ function readRapierPose(body, into) {
2142
2606
  into.wy = w.y;
2143
2607
  into.wz = w.z;
2144
2608
  }
2609
+ function readRapier2dPose(body, into) {
2610
+ const t = body.translation();
2611
+ const angle = body.rotation();
2612
+ const v = body.linvel();
2613
+ into.x = t.x;
2614
+ into.y = t.y;
2615
+ into.z = 0;
2616
+ into.qx = 0;
2617
+ into.qy = 0;
2618
+ into.qz = Math.sin(angle / 2);
2619
+ into.qw = Math.cos(angle / 2);
2620
+ into.vx = v.x;
2621
+ into.vy = v.y;
2622
+ into.vz = 0;
2623
+ into.wx = 0;
2624
+ into.wy = 0;
2625
+ into.wz = body.angvel();
2626
+ }
2145
2627
  function readMatterPose(body, into) {
2146
2628
  into.x = body.position.x;
2147
2629
  into.y = body.position.y;
@@ -2346,6 +2828,95 @@ var RapierScratch = class extends BaseScratch {
2346
2828
  this.world.free();
2347
2829
  }
2348
2830
  };
2831
+ var Rapier2dScratch = class extends BaseScratch {
2832
+ world;
2833
+ rapier;
2834
+ owners = /* @__PURE__ */ new Map();
2835
+ /** Live rigid-body handles the room tracks, so the statics pass knows what to skip. */
2836
+ trackedLive = /* @__PURE__ */ new Set();
2837
+ staticsCloned = false;
2838
+ constructor(physics, depth, tickNow) {
2839
+ super(physics, depth, tickNow);
2840
+ this.rapier = physics.rapier;
2841
+ this.world = new this.rapier.World({ x: 0, y: 0 });
2842
+ this.world.timestep = 0;
2843
+ }
2844
+ partsOf(body) {
2845
+ return body.numColliders();
2846
+ }
2847
+ clone(collection, id, body, parts) {
2848
+ const live = body;
2849
+ this.trackedLive.add(live.handle);
2850
+ const made = this.world.createRigidBody(this.rapier.RigidBodyDesc.fixed());
2851
+ const origin = live.translation();
2852
+ const angle = live.rotation();
2853
+ const cos = Math.cos(-angle);
2854
+ const sin = Math.sin(-angle);
2855
+ for (let i = 0; i < parts; i++) {
2856
+ const c = live.collider(i);
2857
+ const desc = new this.rapier.ColliderDesc(c.shape);
2858
+ desc.setSensor(c.isSensor());
2859
+ const w = c.translation();
2860
+ const dx = w.x - origin.x;
2861
+ const dy = w.y - origin.y;
2862
+ desc.setTranslation(dx * cos - dy * sin, dx * sin + dy * cos);
2863
+ desc.setRotation(c.rotation() - angle);
2864
+ this.world.createCollider(desc, made);
2865
+ }
2866
+ this.owners.set(made.handle, { collection, id });
2867
+ return { collection, id, body: made, parts, lastLive: -1 };
2868
+ }
2869
+ place(entry, pose) {
2870
+ const b = entry.body;
2871
+ if (!b.isEnabled()) b.setEnabled(true);
2872
+ b.setTranslation({ x: pose.x, y: pose.y }, false);
2873
+ b.setRotation(angleOf(pose.qz, pose.qw), false);
2874
+ }
2875
+ hide(entry) {
2876
+ if (entry.body.isEnabled()) entry.body.setEnabled(false);
2877
+ }
2878
+ destroy(entry) {
2879
+ this.owners.delete(entry.body.handle);
2880
+ this.world.removeRigidBody(entry.body);
2881
+ }
2882
+ build(tick, requested, clamped) {
2883
+ this.cloneStatics();
2884
+ this.world.step();
2885
+ const owners = this.owners;
2886
+ return {
2887
+ tick,
2888
+ requested,
2889
+ clamped,
2890
+ rapier2d: {
2891
+ world: this.world,
2892
+ who: (collider) => {
2893
+ const parent = collider.parent();
2894
+ return parent ? owners.get(parent.handle) : void 0;
2895
+ }
2896
+ }
2897
+ };
2898
+ }
2899
+ cloneStatics() {
2900
+ if (this.staticsCloned) return;
2901
+ this.staticsCloned = true;
2902
+ const live = this.physics.world;
2903
+ if (!live) return;
2904
+ live.forEachCollider((c) => {
2905
+ const parent = c.parent();
2906
+ if (parent !== null && (!parent.isFixed() || this.trackedLive.has(parent.handle))) return;
2907
+ const desc = new this.rapier.ColliderDesc(c.shape);
2908
+ desc.setSensor(c.isSensor());
2909
+ const t = c.translation();
2910
+ desc.setTranslation(t.x, t.y);
2911
+ desc.setRotation(c.rotation());
2912
+ this.world.createCollider(desc);
2913
+ });
2914
+ }
2915
+ free() {
2916
+ this.owners.clear();
2917
+ this.world.free();
2918
+ }
2919
+ };
2349
2920
  var MatterScratch = class extends BaseScratch {
2350
2921
  matter;
2351
2922
  owners = /* @__PURE__ */ new Map();
@@ -2395,9 +2966,9 @@ var MatterScratch = class extends BaseScratch {
2395
2966
  }
2396
2967
  build(tick, requested, clamped) {
2397
2968
  const M = this.matter;
2398
- const engine3 = this.physics.matterEngine;
2399
- if (engine3) {
2400
- for (const b of M.Composite.allBodies(engine3.world)) {
2969
+ const engine4 = this.physics.matterEngine;
2970
+ if (engine4) {
2971
+ for (const b of M.Composite.allBodies(engine4.world)) {
2401
2972
  if (b.isStatic) this.visible.push(b);
2402
2973
  }
2403
2974
  }
@@ -2440,7 +3011,7 @@ var RewindState = class {
2440
3011
  `room.rewind: this room has recorded no ticks yet, so there is no past to answer from. A woken room starts with an empty history and fills it over its next ${this.history.depth} tick(s).`
2441
3012
  );
2442
3013
  }
2443
- this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
3014
+ this.scratch ??= physics.engineKind === "rapier3d" ? new RapierScratch(physics, this.history.depth, tickNow) : physics.engineKind === "rapier2d" ? new Rapier2dScratch(physics, this.history.depth, tickNow) : new MatterScratch(physics, this.history.depth, tickNow);
2444
3015
  const view = this.scratch.view(resolved.entry, resolved.tick, requested, resolved.clamped);
2445
3016
  this.inside = true;
2446
3017
  try {
@@ -2451,6 +3022,152 @@ var RewindState = class {
2451
3022
  }
2452
3023
  };
2453
3024
 
3025
+ // src/core/lobby.ts
3026
+ import {
3027
+ LOBBY_MEMBERS,
3028
+ LOBBY_MIN_PLAYERS,
3029
+ LOBBY_STATE,
3030
+ schemaHasLobby
3031
+ } from "@irtio/server";
3032
+ var runtimes = /* @__PURE__ */ new WeakMap();
3033
+ function runtimeOf(core) {
3034
+ let r = runtimes.get(core);
3035
+ if (!r) {
3036
+ r = { onStart: [], started: false };
3037
+ runtimes.set(core, r);
3038
+ }
3039
+ return r;
3040
+ }
3041
+ function lobbyConfigOf(core) {
3042
+ const declared = core.definition.config.lobby;
3043
+ if (declared === void 0) return void 0;
3044
+ if (!schemaHasLobby(core.definition.schema)) return void 0;
3045
+ return declared;
3046
+ }
3047
+ function stateOf3(core) {
3048
+ return core.anyState[LOBBY_STATE];
3049
+ }
3050
+ function evaluateLobby(core) {
3051
+ const config = lobbyConfigOf(core);
3052
+ if (config === void 0) return;
3053
+ const state = stateOf3(core);
3054
+ if (state === void 0) return;
3055
+ const members = trackedEntity(core, LOBBY_MEMBERS);
3056
+ const policy = config.start ?? "when-full";
3057
+ const present = [];
3058
+ for (const [clientId, entry] of core.clients) {
3059
+ if (!entry.connected) continue;
3060
+ present.push(clientId);
3061
+ if (!members.has(clientId)) members.add(clientId, { ready: false }, { owner: clientId });
3062
+ else if (members.ownerOf(clientId) !== clientId) members.setOwner(clientId, clientId);
3063
+ }
3064
+ const here = new Set(present);
3065
+ for (const id of [...members.ids()]) {
3066
+ if (!here.has(id)) members.remove(id);
3067
+ }
3068
+ const readyCount = policy === "when-ready" ? present.filter((id) => members.get(id)?.ready === true).length : 0;
3069
+ const capacity = Math.min(
3070
+ 255,
3071
+ config.size ?? core.definition.config.maxClients,
3072
+ core.definition.config.maxClients
3073
+ );
3074
+ const min = Math.max(LOBBY_MIN_PLAYERS, config.min ?? LOBBY_MIN_PLAYERS);
3075
+ if (state.readyUi !== (policy === "when-ready")) state.readyUi = policy === "when-ready";
3076
+ if (state.present !== present.length) state.present = Math.min(255, present.length);
3077
+ if (state.ready !== readyCount) state.ready = readyCount;
3078
+ if (state.capacity !== capacity) state.capacity = capacity;
3079
+ if (state.phase !== "lobby") return;
3080
+ if (policy === "when-full") {
3081
+ if (present.length >= capacity && capacity >= LOBBY_MIN_PLAYERS) startLobby(core, "when-full");
3082
+ return;
3083
+ }
3084
+ if (policy === "when-ready") {
3085
+ if (present.length >= min && readyCount === present.length) startLobby(core, "when-ready");
3086
+ return;
3087
+ }
3088
+ }
3089
+ function startLobby(core, why) {
3090
+ const runtime = runtimeOf(core);
3091
+ if (runtime.started) return;
3092
+ const state = stateOf3(core);
3093
+ if (state === void 0 || state.phase === "started") {
3094
+ runtime.started = true;
3095
+ return;
3096
+ }
3097
+ runtime.started = true;
3098
+ state.phase = "started";
3099
+ state.public = false;
3100
+ core.host.setLobby({ started: true });
3101
+ core.log("info", `lobby: the game started (${why})`);
3102
+ const declared = lobbyConfigOf(core)?.onStart;
3103
+ if (declared) {
3104
+ core.guard("lobby.onStart", () => declared(core.anyState, core.room));
3105
+ }
3106
+ for (const cb of [...runtime.onStart]) core.guard("lobby.onStart(cb)", cb);
3107
+ }
3108
+ function makeLobby(core) {
3109
+ const config = lobbyConfigOf(core);
3110
+ if (config === void 0) {
3111
+ throw new Error(
3112
+ "room.lobby: this room has no lobby. Spread lobbyCollections into the schema and declare lobby: {} in defineRoom \u2014 both are needed, and defineRoom refuses either one alone"
3113
+ );
3114
+ }
3115
+ const state = stateOf3(core);
3116
+ const members = trackedEntity(core, LOBBY_MEMBERS);
3117
+ return {
3118
+ get phase() {
3119
+ return state.phase ?? "lobby";
3120
+ },
3121
+ get ready() {
3122
+ const out = [];
3123
+ for (const [clientId, entry] of core.clients) {
3124
+ if (!entry.connected) continue;
3125
+ out.push({
3126
+ clientId,
3127
+ ready: members.get(clientId)?.ready === true
3128
+ });
3129
+ }
3130
+ return out;
3131
+ },
3132
+ get public() {
3133
+ return state.public === true;
3134
+ },
3135
+ start() {
3136
+ startLobby(core, "room.lobby.start()");
3137
+ },
3138
+ onStart(cb) {
3139
+ if (typeof cb !== "function") {
3140
+ core.log("warn", "room.lobby.onStart: expects a function; ignored");
3141
+ return;
3142
+ }
3143
+ runtimeOf(core).onStart.push(cb);
3144
+ },
3145
+ setPublic(value) {
3146
+ setLobbyPublic(core, value === true);
3147
+ }
3148
+ };
3149
+ }
3150
+ function setLobbyPublic(core, value) {
3151
+ const config = lobbyConfigOf(core);
3152
+ if (config === void 0) return;
3153
+ const state = stateOf3(core);
3154
+ if (state === void 0) return;
3155
+ if (state.phase === "started") {
3156
+ core.log(
3157
+ "warn",
3158
+ "room.lobby.setPublic: the game has started; the room stays out of the registry"
3159
+ );
3160
+ return;
3161
+ }
3162
+ const veto = config.onSetPublic;
3163
+ if (veto) {
3164
+ const outcome = core.tryRun("lobby.onSetPublic", () => veto(core.anyState, value));
3165
+ if (!outcome.ok || outcome.value === false) return;
3166
+ }
3167
+ if (state.public !== value) state.public = value;
3168
+ core.host.setLobby({ public: value, queue: config.queue ?? "default" });
3169
+ }
3170
+
2454
3171
  // src/core/messages.ts
2455
3172
  import { FrameType as FrameType3, decodeMsg, encodeFrame as encodeFrame2, encodeMsg } from "@irtio/protocol";
2456
3173
  import { decodeFields as decodeFields2 } from "@irtio/schema";
@@ -2601,6 +3318,22 @@ function makeMatterApi(p) {
2601
3318
  }
2602
3319
  };
2603
3320
  }
3321
+ function makeRapier2dApi(p) {
3322
+ return {
3323
+ get rapier() {
3324
+ return p.rapier;
3325
+ },
3326
+ get world() {
3327
+ return p.world;
3328
+ },
3329
+ get timestep() {
3330
+ return p.timestep;
3331
+ },
3332
+ body(collection, id) {
3333
+ return p.bodyFor(collection, id);
3334
+ }
3335
+ };
3336
+ }
2604
3337
  function makeKv(core) {
2605
3338
  return {
2606
3339
  get(playerId, key) {
@@ -2716,6 +3449,7 @@ function createRoomApi(core, publicUrl) {
2716
3449
  let lastNow = Number.NEGATIVE_INFINITY;
2717
3450
  let physicsApi;
2718
3451
  let matterApi;
3452
+ let rapier2dApi;
2719
3453
  let kvApi;
2720
3454
  let busApi;
2721
3455
  let leaderboardApi;
@@ -2738,9 +3472,20 @@ function createRoomApi(core, publicUrl) {
2738
3472
  get id() {
2739
3473
  return core.roomId;
2740
3474
  },
3475
+ /**
3476
+ * The same tracked state object handlers get. The one caller that has no other way to it is
3477
+ * `physics.setup`, which runs before `onCreate` on a fresh room and after the snapshot is
3478
+ * decoded on a rebuild — see the doc on `Room.state`.
3479
+ */
3480
+ get state() {
3481
+ return core.anyState;
3482
+ },
2741
3483
  get link() {
2742
3484
  return link();
2743
3485
  },
3486
+ get seed() {
3487
+ return core.seed;
3488
+ },
2744
3489
  get tick() {
2745
3490
  return core.tick;
2746
3491
  },
@@ -2777,7 +3522,7 @@ function createRoomApi(core, publicUrl) {
2777
3522
  }
2778
3523
  if (p.engineKind !== "rapier3d") {
2779
3524
  throw new Error(
2780
- "room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)"
3525
+ p.engineKind === "matter2d" ? "room.physics: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)" : "room.physics: this room runs rapier2d. Read room.physicsRapier2d instead (same .rapier and .world, but the 2D module: a Vector2 world, not a Vector3 one)"
2781
3526
  );
2782
3527
  }
2783
3528
  return physicsApi ?? (physicsApi = makePhysicsApi(p));
@@ -2791,11 +3536,25 @@ function createRoomApi(core, publicUrl) {
2791
3536
  }
2792
3537
  if (p.engineKind !== "matter2d") {
2793
3538
  throw new Error(
2794
- "room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
3539
+ p.engineKind === "rapier2d" ? "room.physics2d: this room runs rapier2d, not matter2d. Read room.physicsRapier2d instead (it has .rapier and .world, where this one has .matter and .engine)" : "room.physics2d: this room runs rapier3d. Read room.physics instead (it has .rapier and .world, where this one has .matter and .engine)"
2795
3540
  );
2796
3541
  }
2797
3542
  return matterApi ?? (matterApi = makeMatterApi(p));
2798
3543
  },
3544
+ get physicsRapier2d() {
3545
+ const p = core.physics;
3546
+ if (!p) {
3547
+ throw new Error(
3548
+ "room.physicsRapier2d: this room has no physics \u2014 add physics: { engine: 'rapier2d', gravity, bodies } to defineRoom(...)"
3549
+ );
3550
+ }
3551
+ if (p.engineKind !== "rapier2d") {
3552
+ throw new Error(
3553
+ p.engineKind === "matter2d" ? "room.physicsRapier2d: this room runs matter2d. Read room.physics2d instead (it has .matter and .engine, where this one has .rapier and .world)" : "room.physicsRapier2d: this room runs rapier3d. Read room.physics instead (same .rapier and .world, but the 3D module: a Vector3 world, not a Vector2 one)"
3554
+ );
3555
+ }
3556
+ return rapier2dApi ?? (rapier2dApi = makeRapier2dApi(p));
3557
+ },
2799
3558
  // ---- M6 lane F: rewind ----
2800
3559
  // D72: one line, and deliberately only one. Everything the rewind does — the buffer, the
2801
3560
  // clamping, the scratch worlds, the reentrancy refusal — lives in `core/history.ts` behind
@@ -2872,6 +3631,10 @@ function createRoomApi(core, publicUrl) {
2872
3631
  get bus() {
2873
3632
  return busApi ?? (busApi = makeBus(core));
2874
3633
  },
3634
+ // ---- M6 lane H: lobby front door ----
3635
+ get lobby() {
3636
+ return makeLobby(core);
3637
+ },
2875
3638
  alarm(name, atMs) {
2876
3639
  if (!isAlarmName(core, name, "room.alarm")) return;
2877
3640
  if (!Number.isFinite(atMs)) {
@@ -2913,6 +3676,9 @@ function createRoomApi(core, publicUrl) {
2913
3676
  },
2914
3677
  get broadcast() {
2915
3678
  return broadcast;
3679
+ },
3680
+ broadcastExcept(clientId) {
3681
+ return createBroadcastExceptProxy(core, clientId);
2916
3682
  }
2917
3683
  };
2918
3684
  const broadcast = createBroadcastProxy(core);
@@ -3101,8 +3867,13 @@ var RoomCore = class _RoomCore {
3101
3867
  }
3102
3868
  let restored;
3103
3869
  let plain;
3104
- if (options.restoreFrom) {
3105
- restored = parseHibernationBlob(options.restoreFrom);
3870
+ const candidate = options.restoreFrom ? parseHibernationBlob(options.restoreFrom) : void 0;
3871
+ if (candidate && !sameSchemaHash(candidate.schemaHash8, this.ext.hash8)) {
3872
+ host.log("warn", [`irtio: room ${options.roomId}: schema changed, snapshot discarded`]);
3873
+ } else if (candidate) {
3874
+ restored = candidate;
3875
+ }
3876
+ if (restored) {
3106
3877
  plain = decodeSnapshot2(this.ext, restored.snapshot).state;
3107
3878
  const presence = plainEntity(plain, PRESENCE_COLLECTION);
3108
3879
  for (const id of [...presence.ids()]) presence.remove(id);
@@ -3117,7 +3888,7 @@ var RoomCore = class _RoomCore {
3117
3888
  this.plain = plain;
3118
3889
  this.tracked = track(this.ext, plain);
3119
3890
  this.anyState = this.tracked.state;
3120
- this.seed = restored?.seed ?? options.seed ?? 1;
3891
+ this.seed = restored?.seed ?? options.seed ?? seedFromRoomId(this.roomId);
3121
3892
  this.rng = new Mulberry32(restored?.rngState ?? this.seed);
3122
3893
  this.tick = restored?.tick ?? 0;
3123
3894
  const self = this;
@@ -3209,14 +3980,24 @@ var RoomCore = class _RoomCore {
3209
3980
  const config = this.definition.config.physics;
3210
3981
  if (!config) return void 0;
3211
3982
  if (config.engine === "matter2d") return this.buildMatter(config, restored);
3212
- const engine3 = loadedPhysics();
3213
- if (!engine3) {
3983
+ if (config.engine === "rapier2d") return this.buildRapier2d(restored);
3984
+ const engine4 = loadedPhysics();
3985
+ if (!engine4) {
3214
3986
  throw new Error(
3215
3987
  "RoomCore: this room declares physics but the engine is not initialized \u2014 the host must `await initPhysics()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
3216
3988
  );
3217
3989
  }
3218
- const section = restored?.physics ? decodePhysicsSection(restored.physics) : void 0;
3219
- const physics = new PhysicsRuntime(this.internals, engine3, {
3990
+ let section;
3991
+ if (restored?.physics) {
3992
+ const wrote = physicsSectionEngine(restored.physics);
3993
+ if (wrote === "rapier3d") section = decodePhysicsSection(restored.physics);
3994
+ else {
3995
+ this.host.log("warn", [
3996
+ `irtio: this snapshot carries a ${wrote} world and the room now runs rapier3d. The world is rebuilt from schema state and physics.setup runs again.`
3997
+ ]);
3998
+ }
3999
+ }
4000
+ const physics = new PhysicsRuntime(this.internals, engine4, {
3220
4001
  ...section ? { restore: section } : {},
3221
4002
  defaultTimestep: 1 / this.definition.config.tickRate
3222
4003
  });
@@ -3247,11 +4028,12 @@ var RoomCore = class _RoomCore {
3247
4028
  }
3248
4029
  let section;
3249
4030
  if (restored?.physics) {
3250
- if (physicsSectionEngine(restored.physics) === "matter2d") {
4031
+ const wrote = physicsSectionEngine(restored.physics);
4032
+ if (wrote === "matter2d") {
3251
4033
  section = decodeMatterBodies(decodeMatterSectionEnvelope(restored.physics));
3252
4034
  } else {
3253
4035
  this.host.log("warn", [
3254
- "irtio: this snapshot carries a rapier3d world and the room now runs matter2d. The world is rebuilt from schema state and physics.setup runs again."
4036
+ `irtio: this snapshot carries a ${wrote} world and the room now runs matter2d. The world is rebuilt from schema state and physics.setup runs again.`
3255
4037
  ]);
3256
4038
  }
3257
4039
  }
@@ -3268,6 +4050,44 @@ var RoomCore = class _RoomCore {
3268
4050
  physics.reconcile();
3269
4051
  return physics;
3270
4052
  }
4053
+ /**
4054
+ * The rapier2d half. It follows the *rapier3d* shape rather than matter2d's, because the 2D
4055
+ * build has a real `takeSnapshot()`: a restored world comes back whole and `setup` does not run
4056
+ * again. The only thing that differs from `buildPhysics` is which engine and which envelope.
4057
+ */
4058
+ buildRapier2d(restored) {
4059
+ const engine4 = loadedRapier2d();
4060
+ if (!engine4) {
4061
+ throw new Error(
4062
+ "RoomCore: this room declares rapier2d physics but the engine is not initialized \u2014 the host must `await initRapier2d()` (from '@irtio/runtime') before constructing the room; handlers are synchronous, so the WASM cannot be loaded later"
4063
+ );
4064
+ }
4065
+ let section;
4066
+ if (restored?.physics) {
4067
+ const wrote = physicsSectionEngine(restored.physics);
4068
+ if (wrote === "rapier2d") {
4069
+ section = decodePhysicsSection(decodeRapier2dSectionEnvelope(restored.physics));
4070
+ } else {
4071
+ this.host.log("warn", [
4072
+ `irtio: this snapshot carries a ${wrote} world and the room now runs rapier2d. The world is rebuilt from schema state and physics.setup runs again.`
4073
+ ]);
4074
+ }
4075
+ }
4076
+ const physics = new Rapier2dRuntime(this.internals, engine4, {
4077
+ ...section ? { restore: section } : {},
4078
+ defaultTimestep: 1 / this.definition.config.tickRate
4079
+ });
4080
+ if (physics.rebuilt) {
4081
+ if (restored) {
4082
+ this.host.log("info", [
4083
+ `irtio: no rapier2d world in this snapshot (format v${restored.version}) \u2014 rebuilding it from schema state and re-running physics.setup; contact state is not restored`
4084
+ ]);
4085
+ }
4086
+ physics.runSetup(this.room);
4087
+ physics.reconcile();
4088
+ }
4089
+ return physics;
4090
+ }
3271
4091
  /** Convenience for hosts: `RoomCore.restore(def, bytes, host, opts)`. */
3272
4092
  static restore(definition, bytes, host, options) {
3273
4093
  return new _RoomCore(definition, host, { ...options, restoreFrom: bytes });
@@ -3412,7 +4232,13 @@ var RoomCore = class _RoomCore {
3412
4232
  snapshot() {
3413
4233
  const physics = this.physics ? encodeSection(this.physics) : void 0;
3414
4234
  return writeHibernationBlob(
3415
- { seed: this.seed, rngState: this.rng.state, tick: this.tick, mode: this.mode },
4235
+ {
4236
+ seed: this.seed,
4237
+ rngState: this.rng.state,
4238
+ tick: this.tick,
4239
+ mode: this.mode,
4240
+ schemaHash8: this.ext.hash8
4241
+ },
3416
4242
  encodeSnapshot2(this.ext, this.plain, { tick: this.tick }),
3417
4243
  physics
3418
4244
  );
@@ -3756,6 +4582,7 @@ var RoomCore = class _RoomCore {
3756
4582
  * its view's `DELTA` — encoded once per distinct view.
3757
4583
  */
3758
4584
  flush() {
4585
+ evaluateLobby(this.internals);
3759
4586
  const dirty = this.tracked.flush();
3760
4587
  serverWinsCorrections(this.internals, dirty);
3761
4588
  this.ledger?.newFlush();
@@ -3885,7 +4712,13 @@ var RoomCore = class _RoomCore {
3885
4712
  }
3886
4713
  };
3887
4714
  function encodeSection(physics) {
3888
- return physics.engineKind === "matter2d" ? encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize())) : encodePhysicsSection(physics.serialize());
4715
+ if (physics.engineKind === "matter2d") {
4716
+ return encodeMatterSectionEnvelope(encodeMatterBodies(physics.serialize()));
4717
+ }
4718
+ if (physics.engineKind === "rapier2d") {
4719
+ return encodeRapier2dSectionEnvelope(encodePhysicsSection(physics.serialize()));
4720
+ }
4721
+ return encodePhysicsSection(physics.serialize());
3889
4722
  }
3890
4723
 
3891
4724
  export {
@@ -3908,10 +4741,15 @@ export {
3908
4741
  onFirstRapierStep,
3909
4742
  encodePhysicsSection,
3910
4743
  physicsSectionEngine,
4744
+ encodeRapier2dSectionEnvelope,
4745
+ decodeRapier2dSectionEnvelope,
3911
4746
  encodeMatterSectionEnvelope,
3912
4747
  decodeMatterSectionEnvelope,
3913
4748
  decodePhysicsSection,
3914
4749
  Mulberry32,
4750
+ initRapier2d,
4751
+ loadedRapier2d,
4752
+ resetRapier2dForTests,
3915
4753
  isVisible,
3916
4754
  visibleTo,
3917
4755
  visibleNames,