@homie-rocks/studio 0.46.6 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,52 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.47.0] - 2026-10-13
21
+
22
+ **Plugin 0.47.0** · Wrapped worlds and generated collision maps.
23
+
24
+ Games can wrap across map edges and replace seeded tile levels during play, with the same collision geometry in server rules and prediction.
25
+
26
+ ### Added
27
+
28
+ - Maps may opt into wrapping independently on x, y and z with `wrap: {x:true}`;
29
+ periods come from bounds. Bodies, sweeps, rays, overlaps, support, near/box queries,
30
+ area events, ranged effects, spatial delivery, rewind and view interpolation use
31
+ periodic geometry. Crossing an edge wraps rather than clamps. Sweeps/rays retain
32
+ their supplied direction and pay for each periodic image they visit.
33
+ - Room handlers can call `world.map.replace(data)`. A replacement is validated,
34
+ indexed and activated atomically at the next tick, preserving bounds and wrapping.
35
+ Compact current/pending revisions are saved and sent once per collision revision;
36
+ local prediction replays the same compiled map by activation tick.
37
+ - Maps accept row-major `tiles` with width, height, size, optional origin and solid
38
+ height. Nonzero cells compile to indexed horizontal runs. Rules and movement can
39
+ query `map.tile(x,y)`; views read `room.map.tiles`.
40
+ - Named multi-source cardinal `tiles.flows` bake during map compilation. Rules and
41
+ movement use constant-work `map.distance(name,x,y)` and `map.flow(name,x,y)`.
42
+ The standalone rules runtime ships the nav package's new `Flow.js` baker, checked
43
+ against its source in the release gate. Generation, copying, indexing and baking
44
+ are charged to the rules handler; ordinary static-map budget charges are unchanged.
45
+ - Seeded virtual-time release cases cover two-view seam crossing in 2D/3D and
46
+ generated-map collision, mid-round regeneration and restore, including 300 ms
47
+ delay and 12% loss. Geometry and navigation tests cover periodic casts,
48
+ multi-period movement, atomic failure and independently checked shortest paths.
49
+ Eight-player generation/restore replays also match every save and snapshot
50
+ across Node, Chrome and workerd for 240 seeded ticks.
51
+
52
+ ### Fixed
53
+
54
+ - Handlers reached synchronously by `world.dispatch` now report their coverage to
55
+ the build checker, including failures, without opening a second handler budget.
56
+ - The rewrite guide specifies named helper re-exports (`export *` is refused), and
57
+ the rules guide explains that mandatory spawn fields precede deferred `arrive`.
58
+
59
+ This release covers wrapped worlds and generated levels. Arbitrary triangle-mesh
60
+ collision, the measured Rapier-versus-kernel decision, first-class integration
61
+ substeps, and the assisted rewrite command remain follow-up work.
62
+
20
63
  ## [0.46.6] - 2026-10-11
21
64
 
22
- **Plugin 0.46.6** · Big rooms: what one room carries, measured.
65
+ **Plugin 0.46.6** · Big rooms: what one room carries, measured. · [release-2026-10-11-studio-0.46.6](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-11-studio-0.46.6)
23
66
 
24
67
  What one room object carries on Cloudflare, measured in six trials and stated: with a light game at 20 ticks a second, 300 players with margin, 600 at the edge, 800 to 1,000 only by luck.
25
68
 
@@ -17,7 +17,8 @@ Read RULES.md before starting.
17
17
  An old file named rules.ts may already export pure helpers that tests import.
18
18
  Preserve that public test API and every existing test file byte-for-byte,
19
19
  including imports. Re-export the old helpers from rules.ts if extracted into
20
- another module; do not redirect tests to that module. Record test-file hashes
20
+ another module with named exports (`export { helper } from './helper'`);
21
+ `export *` is refused. Do not redirect tests to that module. Record test-file hashes
21
22
  before editing and compare them at the end. The filename alone does not
22
23
  mean it is a rules-plus-view game. If tests now load toolkit TypeScript under
23
24
  Node, use a narrowly scoped TypeScript loader and keep the normal test command
@@ -1178,3 +1178,70 @@ still snap to authority. Opt in only after measuring stop/reversal and delayed
1178
1178
  collision behavior for the game's mover.
1179
1179
 
1180
1180
  For multi-cast movement or scratch trajectory checks, `ctx.world.sweep(body, delta, {ground:false})` (also captured-scene `sweep`) moves and returns the same collision but leaves `body.grounded` unchanged. Use it only when your mover computes support explicitly. Omitting the option retains the existing support query and grounding behavior. This avoids paying for identical support checks after horizontal and upward probes.
1181
+
1182
+
1183
+ ## Wrapped worlds and generated levels (0.47.0)
1184
+
1185
+ Declare wrapping in the map, independently per axis. The period is the bounds'
1186
+ extent; an omitted axis keeps its existing bounded behaviour:
1187
+
1188
+ ```json
1189
+ {"bounds":{"min":[0,0,0],"max":[25,25,10]},"wrap":{"x":true}}
1190
+ ```
1191
+
1192
+ Bodies and hit positions use canonical coordinates in `[min,max)`. Sweeps and rays
1193
+ follow the supplied displacement through periodic copies of the geometry; they can
1194
+ cross more than one period and pay for each visited image. Near/box queries, area
1195
+ events, effect ranges, spatial delivery, rewind and drawing interpolation use the
1196
+ short way around. Exactly half a period chooses the negative direction. A renderer
1197
+ can read `room.map.wrap.x` (the pair `[min,max]`) to draw copies at the viewport's
1198
+ edges. The server does not duplicate entities. In a maze with one tunnel, keep
1199
+ solid border cells on every other row; wrapping itself is per axis, not per row.
1200
+
1201
+ For a generated maze, replace the room's current map in `room.start` or a room
1202
+ handler. Build its data using the rules' seeded random source, then publish once:
1203
+
1204
+ ```ts
1205
+ world.map.replace({
1206
+ bounds: { min: [0, 0], max: [25, 25] },
1207
+ wrap: { x: true },
1208
+ tiles: { width: 25, height: 25, size: 1, data: cells,
1209
+ flows: { exit: [312] } },
1210
+ spots: { start: [[12.5, 19.5]] },
1211
+ });
1212
+ ```
1213
+
1214
+ `replace` preserves the original bounds and wrapping. It validates, bakes cardinal
1215
+ navigation fields and compiles the spatial index before publishing anything. A
1216
+ failed bake leaves the previous revision intact. The last successful replacement
1217
+ in a tick becomes current at the next tick; all bodies use the same revision.
1218
+ The compact map data and activation tick travel with collision revisions and saves,
1219
+ including both current and pending maps. Prediction replays against that tick's
1220
+ compiled geometry. Late knowledge is corrected by the normal reconciliation path.
1221
+
1222
+ Tiles are row-major integers: zero is open, nonzero is solid. `at` defaults to
1223
+ `[0,0,0]`, `size` and `solidHeight` to 1. Horizontal runs become indexed boxes,
1224
+ including in 3D. On a wrapped axis the grid must span the full map. Authored boxes,
1225
+ round shapes, terrain and spots can be included in the same replacement. The view
1226
+ reads `room.map.tiles`; rules and movement read `world.map.tile(x,y)` and
1227
+ `ctx.map.tile(x,y)`. Cell coordinates are integers, not metres. Outside an unwrapped
1228
+ grid is solid. A custom tile mover may read these cells instead of using sweeps.
1229
+
1230
+ `flows` names lists of open destination cell indices. `map.distance(name,x,y)` gives
1231
+ cardinal hops to the nearest destination, or -1 when unreachable/unknown;
1232
+ `map.flow(name,x,y)` gives the next unit direction, zero at a destination, or
1233
+ undefined when unreachable/unknown. Each lookup is constant work (4 or 8 budget
1234
+ units). Baking uses `@homie-rocks/nav`'s shared cardinal flow implementation;
1235
+ left/up/right/down resolves ties, including tunnels. Change destinations by
1236
+ publishing new flow declarations with the map. This is a bake, not an unmetered
1237
+ path search: budget for every field and regenerate only when its goals change.
1238
+
1239
+ Room replacement charges copying, grid scanning, index construction and each flow
1240
+ bake to the calling handler. Snapshots charge for the compact data. Existing static
1241
+ maps need no replacement and retain their previous budget charges. A few thousand
1242
+ cells regenerated a few times a round belong in one map revision; live collider
1243
+ entities remain suitable for independently moving doors, cover and platforms.
1244
+
1245
+ `spawn` exposes a new entity before its deferred `on.arrive` handler. Pass every
1246
+ field that must be valid immediately in the spawn data; use `arrive` for subsequent
1247
+ work, not for mandatory initialization.
@@ -75,6 +75,12 @@ Cloudflare or physical-phone capacity guarantees.
75
75
 
76
76
  ## Write and change rules plus view
77
77
 
78
+ For wrapping worlds or generated levels, use the map's opt-in `wrap` and room-scoped
79
+ `world.map.replace` APIs described in RULES.md's **Wrapped worlds and generated levels**.
80
+ Prefer one indexed map revision for a generated maze; keep independently moving
81
+ doors and platforms as live colliders. Generation and navigation baking spend the
82
+ room handler's budget.
83
+
78
84
  **Read [RULES.md](RULES.md) before writing code.** All five starters use this contract.
79
85
  Work in few, large edits: read each source file once, decide its changes, then
80
86
  make every change it needs in ONE file_edit with an edits list. Build after each
@@ -1,3 +1,4 @@
1
+ import { boundPosition } from '../rules/topology.mjs';
1
2
  /**
2
3
  * The generated play of a rules game, in a worker thread of its own (rules-check.mjs starts it).
3
4
  *
@@ -353,7 +354,7 @@ function play(c, companions, allow) {
353
354
  error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
354
355
  units += result.used;
355
356
  const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
356
- result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: c.dims === 3 ? Math.max(bounds.min.z, Math.min(bounds.max.z - height, result.pos.z)) : 0 }, c.dims);
357
+ result.pos = L.P.vec3(boundPosition(result.pos,geometry??c.map,r,height,c.dims),c.dims);
357
358
  delete result.used;
358
359
  if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
359
360
  }
@@ -66,11 +66,11 @@ export const REFUSED_NAMES = ['constructor', 'prototype', '__proto__', 'stack',
66
66
  /** The names JavaScript calls by itself to turn an object into a number or a text: no function may sit under one. */
67
67
  export const HOOK_NAMES = ['valueOf', 'toString', 'toJSON'];
68
68
  /** What the host runtime's own objects offer: `world`, `ctx`, the round, the map (rules/core.ts) and `world.math` (rules/math.ts). */
69
- export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots'];
69
+ export const WORLD_METHODS = ['ticks', 'random', 'route', 'label', 'send', 'dispatch', 'sendRoom', 'announce', 'sendArea', 'after', 'emit', 'spawn', 'despawn', 'place', 'near', 'inBox', 'ray', 'rayAll', 'rays', 'sweep', 'ask', 'goalDone', 'finish', 'end', 'restart', 'spot', 'spots', 'replace', 'tile', 'distance', 'flow'];
70
70
  export const MATH_METHODS = ['sin', 'cos', 'tan', 'atan', 'atan2', 'asin', 'acos', 'exp', 'log', 'pow', 'hypot', 'rad', 'deg', 'clamp', 'lerp', 'vec', 'add', 'sub', 'scale', 'dot', 'cross', 'len', 'dist', 'norm', 'clampLen', 'lerpVec', 'dir', 'angle'];
71
71
  const VALUE_METHODS = new Set([...ARRAY_METHODS, ...STRING_METHODS, ...MAPSET_METHODS]);
72
72
  export const MOVE_METHODS = ['support', 'overlaps'];
73
- const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart']);
73
+ const NEW_METHODS = new Set(['route','label','dispatch','rayAll','rays','restart','replace','tile','distance','flow']);
74
74
  const CALLABLE = new Set([...VALUE_METHODS, ...WORLD_METHODS, ...MOVE_METHODS, ...MATH_METHODS]);
75
75
  const refusedName = (k) => REFUSED_NAMES.includes(k) || k.startsWith('toLocale');
76
76
  /** Operators that turn each operand into a number or a text. (`+` is handled beside them; `==` and `!=` are refused.) */
@@ -2901,6 +2901,48 @@
2901
2901
  }
2902
2902
  }
2903
2903
  }
2904
+ },
2905
+ "0.47.0": {
2906
+ "files": {
2907
+ "CLAUDE.md": "ddf2b2a0376202f6",
2908
+ "README.md": "684b3cca8001673c",
2909
+ "HANDOFF.md": "29337e2ace95b2e7",
2910
+ "games/README.md": "5ad32fe80c61f117",
2911
+ "music/README.md": "a3dc042dadd39df8",
2912
+ "videos/README.md": "16babc3437d5545b",
2913
+ "posts/README.md": "37841aae476bd540",
2914
+ "site/README.md": "732894273a32dc3f",
2915
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
2916
+ },
2917
+ "sections": {
2918
+ "AGENTS.md": {
2919
+ "(intro)": "394cbbe934e80722",
2920
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
2921
+ "## Layout": "f3a280b8c96cd068",
2922
+ "## Apps: one screen, roles and parts": "8596f2b25edda3e2",
2923
+ "## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
2924
+ "## The site": "0cb8dd9a6f8a0096",
2925
+ "## Making games": "bb715272fd1a615b",
2926
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2927
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2928
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2929
+ "## Servers and AI seats": "1ccfd549d2369a60",
2930
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2931
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2932
+ "## Rules": "27881d11d3ff3803",
2933
+ "## Telling Homie": "80b46fdd9e578db8",
2934
+ "## Beta": "9d02638207a26e73"
2935
+ }
2936
+ },
2937
+ "named": {
2938
+ "Homie Arcade": {
2939
+ "sections": {
2940
+ "AGENTS.md": {
2941
+ "## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
2942
+ }
2943
+ }
2944
+ }
2945
+ }
2904
2946
  }
2905
2947
  }
2906
2948
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.46.6",
3
+ "version": "0.47.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -11,6 +11,7 @@ export interface BodyShape { shape: string; radius: number; height: number; terr
11
11
  export interface Solid { min: Vec3; max: Vec3; r: number }
12
12
  export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
13
13
  export interface Map3 {
14
+ wrap?: import('./rules.ts').MapWrap;
14
15
  staticMap?: Map3;
15
16
  bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
16
17
  heightTiles?: readonly MapHeightTile[];
package/rules/core.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { prepareMapReplacement, type MapRevision } from './map-state.ts';
2
+ import { tileApi } from './tiles.ts';
3
+ import { boundPosition, periodicDelta, relativePoint, blendPosition, wrapPoint } from './topology.mjs';
1
4
  import {routeGraph} from './navigation.ts';
2
5
  import { rayQuery, raySnapshot, type QueryTarget } from './query.ts';
3
6
  import { colliderSolid, castCollider2, colliderRow, collisionMap, collisionQueries, type CollisionRevision } from './live.ts';
@@ -141,6 +144,7 @@ export interface SavedCore {
141
144
  round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
142
145
  shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
143
146
  collision?: [number,number];
147
+ maps?: {current:MapRevision|null;pending:MapRevision|null};
144
148
  history?: [number, [string, number, number, number, number][]][];
145
149
  asks: PendingAsk[];
146
150
  guideViews: [number, Record<string, unknown>][];
@@ -191,6 +195,9 @@ export interface Core {
191
195
  * `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
192
196
  */
193
197
  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 {
198
+ let currentMap=c.map;
199
+ let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
200
+ let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
194
201
  const dims = c.dims;
195
202
  const tickHz = c.settings.tickHz;
196
203
  const dt = 1 / tickHz;
@@ -356,7 +363,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
356
363
  naming(kind.name, 'fields'); const f = coerceFields(kind.fields, init.fields, dims);
357
364
  naming(kind.name, 'motion'); const m = coerceFields(kind.motion, init.motion, dims); naming();
358
365
  const e: Ent = {
359
- id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(c.map, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
366
+ id: `e${n.toString(36)}`, n, kind, pos: clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), vel: ZERO, heading: init.heading === undefined ? AHEAD : dir(init.heading, dims), grounded: dims === 2 || restsOnMap(currentMap, clampIn(V(at), kind.body?.radius ?? 0, kind.body ? kind.body.height || 2 * kind.body.radius : 0), kind.body ?? { shape: 'sphere', radius: 0, height: 0 }),
360
367
  r: 0, born: tick, arrived: false, why, dead: false,
361
368
  f, m,
362
369
  seat: player ? player.seat : -1, owner: player ? player.owner : '', driver: player ? player.driver : 'bot', away: false, goal: null,
@@ -366,10 +373,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
366
373
  return e;
367
374
  }
368
375
  function clampIn(p: Vec3, r: number, height = 0): Vec3 {
369
- const b = c.map.bounds;
370
- const x = Math.max(b.min.x + r, Math.min(b.max.x - r, p.x)); const y = Math.max(b.min.y + r, Math.min(b.max.y - r, p.y));
371
- const z = dims === 3 ? Math.max(b.min.z, Math.min(b.max.z - height, p.z)) : 0;
372
- return x === p.x && y === p.y && z === p.z ? p : V({ x, y, z });
376
+ if(!currentMap.wrap){
377
+ const b=currentMap.bounds;
378
+ const x=Math.max(b.min.x+r,Math.min(b.max.x-r,p.x));
379
+ const y=Math.max(b.min.y+r,Math.min(b.max.y-r,p.y));
380
+ const z=dims===3?Math.max(b.min.z,Math.min(b.max.z-height,p.z)):0;
381
+ return x===p.x&&y===p.y&&z===p.z?p:V({x,y,z});
382
+ }
383
+ const at = boundPosition(p,currentMap,r,height,dims);
384
+ return at.x===p.x&&at.y===p.y&&at.z===p.z?p:V(at);
373
385
  }
374
386
 
375
387
  /* ---------------------------------------------------------------- running a handler under the budget */
@@ -470,9 +482,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
470
482
  return k;
471
483
  };
472
484
  const mapApi = brand(Object.freeze({
473
- name: c.map.name,
474
- spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(c.map.spots, name) ? c.map.spots[name][0] : undefined),
475
- spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(c.map.spots, name) ? c.map.spots[name] : NO_SPOTS),
485
+ get name(){return currentMap.name;},
486
+ ...tileApi(()=>currentMap),
487
+ replace:(raw:unknown)=>{
488
+ need('room','world.map.replace()');
489
+ const prepared=prepareMapReplacement(raw,c.map);
490
+ pendingMap=Object.freeze([tick+1,prepared.raw]);pendingGeometry=prepared.map;pendingMapCells=prepared.cells;mapCells=currentMapCells+pendingMapCells;
491
+ },
492
+ spot: (name: unknown): Vec3 | undefined => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name][0] : undefined),
493
+ spots: (name: unknown): readonly Vec3[] => (typeof name === 'string' && Object.hasOwn(currentMap.spots, name) ? currentMap.spots[name] : NO_SPOTS),
476
494
  }));
477
495
  let roundApi: Readonly<Record<string, unknown>> = Object.freeze({});
478
496
  const refreshRound = (): void => {
@@ -512,13 +530,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
512
530
  const row=e.kind.collider?colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f):null;
513
531
  if(e.kind.collider&&!row)continue;
514
532
  let at=e.pos;
515
- if(!e.kind.collider && wanted<tick && before && after){charge(4*(before[1].length+after[1].length));const a=before[1].find(p=>p[0]===e.id&&p[4]===e.r),b=after[1].find(p=>p[0]===e.id&&p[4]===e.r);if(a&&b){const mix=after[0]===before[0]?0:Math.max(0,Math.min(1,(wanted-before[0])/(after[0]-before[0])));at={x:a[1]+(b[1]-a[1])*mix,y:a[2]+(b[2]-a[2])*mix,z:a[3]+(b[3]-a[3])*mix};}}
533
+ if(!e.kind.collider && wanted<tick && before && after){charge(4*(before[1].length+after[1].length));const a=before[1].find(p=>p[0]===e.id&&p[4]===e.r),b=after[1].find(p=>p[0]===e.id&&p[4]===e.r);if(a&&b){const mix=after[0]===before[0]?0:Math.max(0,Math.min(1,(wanted-before[0])/(after[0]-before[0])));at=blendPosition({x:a[1],y:a[2],z:a[3]},{x:b[1],y:b[2],z:b[3]},mix,currentMap.wrap);}}
516
534
  targets.push({id:e.id,kind:e.kind.name,query:e.kind.query,fields:e.f,geometry:Boolean(row),solid:row?colliderSolid(row):solidAt(at,e.kind.body),at});
517
535
  }
518
536
  return targets;
519
537
  }
520
538
  function cast3(p: Vec3, d: Vec3, shape: BodyShape, ignored: Set<string>): Hit3 | null {
521
- let best = castMap3(c.map, p, d, shape);
539
+ let best = castMap3(currentMap, p, d, shape);
522
540
  const solid = solidAt(p, shape);
523
541
  for (const other of ents.values()) {
524
542
  if (!other.kind.body || ignored.has(other.id)) continue;
@@ -568,8 +586,16 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
568
586
  const parent = cx, parentTouched = touched, note = G.note;
569
587
  cx = {scope:'ent',ent:e}; touched = []; dispatchDepth++;
570
588
  if (noted) G.note = (what, at, written) => noted(e.kind.name, `on.${String(ev)}`, what, at, written);
589
+ let failure: unknown;
571
590
  try { fn(world,e.self,payload); }
572
- finally { try { settle(); } finally { cx=parent; touched=parentTouched; G.note=note; dispatchDepth--; } }
591
+ catch(error) { failure=error; throw error; }
592
+ finally {
593
+ try { settle(); } catch(error) { failure=error; throw error; }
594
+ finally {
595
+ cx=parent; touched=parentTouched; G.note=note; dispatchDepth--;
596
+ opts.observe?.(e.kind.name, `on.${String(ev)}`, failure===undefined?undefined:errorText(failure));
597
+ }
598
+ }
573
599
  return true;
574
600
  },
575
601
  sendRoom: (ev: unknown, data?: unknown): void => {
@@ -651,7 +677,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
651
677
  e.pos = clampIn(V(at), e.kind.body?.radius ?? 0, e.kind.body ? e.kind.body.height || 2 * e.kind.body.radius : 0);
652
678
  e.vel = vel !== undefined ? V(vel) : ZERO;
653
679
  if (heading !== undefined) e.heading = dir(heading, dims);
654
- e.grounded = dims === 2 || restsOnMap(c.map, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
680
+ e.grounded = dims === 2 || restsOnMap(currentMap, e.pos, e.kind.body ?? { shape: 'sphere', radius: 0, height: 0 });
655
681
  e.r = (e.r + 1) & 0xffff;
656
682
  e.allow = 0;
657
683
  },
@@ -662,8 +688,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
662
688
  let cost = 0;
663
689
  for (const e of ents.values()) {
664
690
  if (k && e.kind !== k) continue;
665
- const dx = e.pos.x - p.x; const dy = e.pos.y - p.y;
666
- const dz = dims === 3 ? e.pos.z - p.z : 0;
691
+ const dx = periodicDelta(p.x,e.pos.x,currentMap.wrap?.x); const dy = periodicDelta(p.y,e.pos.y,currentMap.wrap?.y);
692
+ const dz = dims === 3 ? periodicDelta(p.z,e.pos.z,currentMap.wrap?.z) : 0;
667
693
  const d2 = dx * dx + dy * dy + dz * dz;
668
694
  if (d2 <= R * R) { found.push({ d: d2, e }); cost += viewCost.get(e.kind) as number; }
669
695
  }
@@ -678,12 +704,13 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
678
704
  if (max.x - min.x > 2 * REACH_M || max.y - min.y > 2 * REACH_M || dims === 3 && max.z - min.z > 2 * REACH_M) throw new Error(`world.inBox reaches ${REACH_M} m from its centre at most`);
679
705
  const found: Ent[] = [];
680
706
  let cost = 0;
681
- for (const e of ents.values()) if ((!k || e.kind === k) && e.pos.x >= min.x && e.pos.x <= max.x && e.pos.y >= min.y && e.pos.y <= max.y && (dims === 2 || e.pos.z >= min.z && e.pos.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }
707
+ const centre={x:(min.x+max.x)/2,y:(min.y+max.y)/2,z:(min.z+max.z)/2};
708
+ for (const e of ents.values()) {const p=relativePoint(e.pos,centre,currentMap.wrap);if ((!k || e.kind === k) && p.x >= min.x && p.x <= max.x && p.y >= min.y && p.y <= max.y && (dims === 2 || p.z >= min.z && p.z <= max.z)) { found.push(e); cost += viewCost.get(e.kind) as number; }}
682
709
  charge(cost);
683
710
  return Object.freeze(found.map(viewOf));
684
711
  },
685
712
  ray: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => {
686
- if (options !== undefined) return rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
713
+ if (options !== undefined || currentMap.wrap) return rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id)[0];
687
714
  // Charged before the cast, for every shape it may test: the map's, and every entity in the room.
688
715
  charge(20 + 4 * ents.size);
689
716
  const p = V(from); const d = dir(direction, dims); const far = reach(max, 'world.ray');
@@ -692,7 +719,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
692
719
  const hit = cast3(p, delta, { shape: 'sphere', radius: 0, height: 0 }, new Set(cx.ent ? [cx.ent.id] : []));
693
720
  return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: V({ x: p.x + delta.x * hit.t, y: p.y + delta.y * hit.t, z: p.z + delta.z * hit.t }), normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }), dist: far * hit.t }) : undefined;
694
721
  }
695
- const cast = castMap(c.map, p.x, p.y, d.x * far, d.y * far, 0);
722
+ const cast = castMap(currentMap, p.x, p.y, d.x * far, d.y * far, 0);
696
723
  let best: Hit | null = cast.hit;
697
724
  for (const e of ents.values()) {
698
725
  if (!e.kind.body || e === cx.ent) continue;
@@ -711,12 +738,19 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
711
738
  const named=typeof graph==='string';if(named&&!Object.hasOwn(c.navigation,graph))throw new Error('unknown navigation graph');return routeGraph(named?c.navigation[graph]:graph,from,to,rayTargets({geometryOnly:true}).map(t=>t.solid),radius,height,named);
712
739
  },
713
740
  label: (id: unknown): string => {charge(20);const e=typeof id==='string'?ents.get(id):undefined;if(!e?.kind.player)return '';return String(opts.label?.(e.seat,e.driver)??`Player ${e.seat+1}`).slice(0,40);},
714
- rays: (options?: unknown): unknown => raySnapshot(c.map,rayTargets(options),dims,options,cx.ent?.id),
715
- rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(c.map, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
741
+ rays: (options?: unknown): unknown => raySnapshot(currentMap,rayTargets(options),dims,options,cx.ent?.id),
742
+ rayAll: (from: unknown, direction: unknown, max: unknown, options?: unknown): unknown => rayQuery(currentMap, rayTargets(options), dims, from, direction, max, options, cx.ent?.id, true),
716
743
  sweep: (self: unknown, delta: unknown, o?: unknown): unknown => {
717
744
  const e = mine(self, 'world.sweep');
718
745
  charge(20 + 4 * ents.size);
719
746
  const d = V(delta);
747
+ if(currentMap.wrap){
748
+ const list=own(o,'ignore'),ignore:string[]=[];
749
+ if(Array.isArray(list))for(let i=0;i<list.length&&i<IGNORE_MAX;i++){const id=own(list,i);if(typeof id==='string')ignore.push(id);}
750
+ const scene=raySnapshot(currentMap,rayTargets(undefined),dims,{radius:e.kind.body?.radius??0,height:e.kind.body?.height||2*(e.kind.body?.radius??0),shape:e.kind.body?.shape==='circle'?'sphere':e.kind.body?.shape??'sphere',ignore},e.id);
751
+ const body={pos:e.pos,grounded:e.grounded};const hit=scene.sweep(body,d);
752
+ e.pos=V(wrapPoint(body.pos,currentMap.wrap));e.grounded=body.grounded;return hit;
753
+ }
720
754
  const radius = e.kind.body?.radius ?? 0;
721
755
  // The ids to pass through: the first few of a list, read as plain texts. A longer list is not searched once for every entity.
722
756
  const list = own(o, 'ignore');
@@ -733,7 +767,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
733
767
  e.grounded = Boolean(support && support.nz > 0.5);
734
768
  return hit ? Object.freeze({ ...(hit.id ? { entity: hit.id } : {}), at: e.pos, normal: V({ x: hit.nx, y: hit.ny, z: hit.nz }) }) : undefined;
735
769
  }
736
- const cast = castMap(c.map, e.pos.x, e.pos.y, d.x, d.y, radius);
770
+ const cast = castMap(currentMap, e.pos.x, e.pos.y, d.x, d.y, radius);
737
771
  let best: Hit | null = cast.hit;
738
772
  for (const other of ents.values()) {
739
773
  if (other === e || !other.kind.body || ignore.has(other.id)) continue;
@@ -778,14 +812,15 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
778
812
  const row = colliderRow(e.id,e.pos,e.kind.body,e.kind.collider,e.f,e.kind.name,e.kind.query);
779
813
  return row ? [row] : [];
780
814
  });
781
- const text = JSON.stringify(rows);
782
- if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows];}
815
+ const metadata={...(currentMap.wrap?{wrap:currentMap.wrap}:{}),...((pendingMap??mapRevision)?{map:pendingMap??mapRevision,...(pendingMap&&mapRevision?{previousMap:mapRevision}:{})}:{})};
816
+ const text = JSON.stringify(Object.keys(metadata).length ? [rows,metadata] : rows);
817
+ if (!collisionPrevious || text !== collisionText) {collisionText=text;collisionPrevious=[...(collisionPrevious ? [tick,tick+1] : opts.restore?.collision ?? [tick,tick+1]) as [number,number],rows,...(Object.keys(metadata).length?[metadata]:[])];}
783
818
  return collisionPrevious;
784
819
  };
785
- let moveGeometry = c.map as import('./math.ts').MapShapes;
820
+ let moveGeometry = currentMap as import('./math.ts').MapShapes;
786
821
  const queries = collisionQueries(() => moveGeometry, () => moveShape, dims, () => cx.ent?.id);
787
822
  const moveWorld = brand(Object.freeze(queries));
788
- const moveMap = brand(Object.freeze({ name: c.map.name, spot: mapApi.spot, spots: mapApi.spots, ...queries }));
823
+ const moveMap = brand(Object.freeze({ get name(){return currentMap.name;}, spot: mapApi.spot, spots: mapApi.spots, ...tileApi(()=>currentMap), ...queries }));
789
824
  let moveShape = { shape: 'sphere', radius: 0, height: 0 };
790
825
  const moveCtx = brand({} as Record<string, unknown>);
791
826
  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 });
@@ -1037,6 +1072,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1037
1072
  /* ---------------------------------------------------------------- one tick */
1038
1073
 
1039
1074
  function inArea(a: any, p: Vec3): boolean {
1075
+ if(currentMap.wrap) p = relativePoint(p, a.k === 'b' ? {x:(a.min.x+a.max.x)/2,y:(a.min.y+a.max.y)/2,z:(a.min.z+a.max.z)/2} : a.at, currentMap.wrap);
1040
1076
  if (a.k === 'b') return p.x >= a.min.x && p.x <= a.max.x && p.y >= a.min.y && p.y <= a.max.y && (dims === 2 || p.z >= a.min.z && p.z <= a.max.z);
1041
1077
  const dx = p.x - a.at.x; const dy = p.y - a.at.y;
1042
1078
  const dz = dims === 3 ? p.z - a.at.z : 0;
@@ -1049,6 +1085,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1049
1085
  }
1050
1086
  function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
1051
1087
  tick = (tick + 1) >>> 0;
1088
+ if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
1052
1089
  // 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.
1053
1090
  left = c.settings.budget.tick - SNAP * snapCells;
1054
1091
  cut = false; stopped = false; threw = null;
@@ -1107,7 +1144,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1107
1144
  }
1108
1145
  guides?.();
1109
1146
  // All movers see the same world, independent of entity iteration order.
1110
- if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(c.map, collisionState()[2]); }
1147
+ if (hasColliders) { left -= 64 * ents.size; moveGeometry = collisionMap(currentMap, collisionState()[2]); }
1111
1148
  // Phase 1: one input step and `move` for every body.
1112
1149
  for (const e of [...ents.values()]) {
1113
1150
  if (e.dead || !e.kind.body) continue;
@@ -1140,7 +1177,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1140
1177
  // Owner movement: the browser says where its body is, and the server holds the claim to maxSpeed.
1141
1178
  e.allow = Math.min(e.allow + body.maxSpeed * dt * 1.02, body.maxSpeed * 0.25 + 0.05);
1142
1179
  if (claim && claim.r === e.r) {
1143
- const dx = claim.pos.x - e.pos.x; const dy = claim.pos.y - e.pos.y;
1180
+ const dx = periodicDelta(e.pos.x,claim.pos.x,currentMap.wrap?.x); const dy = periodicDelta(e.pos.y,claim.pos.y,currentMap.wrap?.y);
1144
1181
  const dist = Math.sqrt(dx * dx + dy * dy);
1145
1182
  const go = Math.min(dist, e.allow);
1146
1183
  // The browser ran the game's own `move` and came further than `maxSpeed` allows: the body is held back.
@@ -1153,7 +1190,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1153
1190
  e.grounded = dims === 2;
1154
1191
  // The support cast is map work even when a browser supplies the pose.
1155
1192
  // Keep it under the same movement quota as a server-driven body's sweep.
1156
- if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(c.map, e.pos, body); }, 'body');
1193
+ if (dims === 3) run(k.name, 'move', e, 'move', () => { e.grounded = restsOnMap(currentMap, e.pos, body); }, 'body');
1157
1194
  }
1158
1195
  continue;
1159
1196
  }
@@ -1245,7 +1282,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1245
1282
  function save(): SavedCore {
1246
1283
  return {
1247
1284
  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,
1248
- ...(hasColliders?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1285
+ ...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
1286
+ ...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
1249
1287
  ...(historyTicks?{history:poseHistory.map(([at,rows])=>[at,rows.map(row=>[...row])] as SavedCore['history'])}:{}),
1250
1288
  shared: packFields(c.shared, shared, dims), policy: { ...policy }, intent: restartAsked ? [endAsked ? 1 : 0, finishing ? 1 : 0, 1] : [endAsked ? 1 : 0, finishing ? 1 : 0], asks: pendingAsks, guideViews: [...guideViews],
1251
1289
  ents: [...ents.values()].map(saveEnt), spawns: spawns.map(saveEnt),
@@ -1264,8 +1302,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1264
1302
  check(Array.isArray(value) && JSON.stringify(value) === JSON.stringify(packFields(list, unpackFields(list, value, dims), dims)));
1265
1303
  };
1266
1304
  check(r.v === SAVE_REVISION);
1305
+ if(r.maps!==undefined){
1306
+ check(r.maps&&Object.keys(r.maps).length===2&&'current' in r.maps&&'pending' in r.maps&&Object.keys(r.maps).every(k=>k==='current'||k==='pending'));
1307
+ 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
+ }
1267
1309
  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);
1268
- 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'].includes(k)));
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)));
1269
1311
  const askNames = new Set();
1270
1312
  check(Array.isArray(r.asks));
1271
1313
  for (const a of r.asks) {
@@ -1311,7 +1353,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1311
1353
  for (const i of [7, 8, 9]) check(JSON.stringify(w[i]) === JSON.stringify(packVec(unpackVec(w[i], 3), 3)));
1312
1354
  const goal = w[15] as any;
1313
1355
  if (goal !== null) check(goal && typeof goal === 'object' && !Array.isArray(goal) && Object.keys(goal).every(k => ['goal', 'args', 'from', 'at', 'asked', 'state'].includes(k)) && typeof goal.goal === 'string' && goal.args && typeof goal.args === 'object' && !Array.isArray(goal.args) && ['brain', 'floor'].includes(goal.from) && uint(goal.at) && goal.at <= r.tick && typeof goal.asked === 'boolean' && goal.state === 'active');
1314
- const pos = unpackVec(w[7] as number[], 3), bounds = c.map.bounds;
1356
+ const pos = unpackVec(w[7] as number[], 3), bounds = currentMap.bounds;
1315
1357
  check(pos.x >= bounds.min.x && pos.x <= bounds.max.x && pos.y >= bounds.min.y && pos.y <= bounds.max.y && (dims === 3 ? pos.z >= bounds.min.z && pos.z <= bounds.max.z : pos.z === 0));
1316
1358
  check(Number.isInteger(w[11]) && (w[11] as number) >= -1 && (w[11] as number) < c.seats && ['person', 'bot', 'ai'].includes(w[12] as string) && typeof w[13] === 'string' && w[13].length <= 128);
1317
1359
  if (k.player) { check(!bodySeats.has(w[11])); bodySeats.add(w[11]); }
@@ -1398,6 +1440,12 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1398
1440
  }
1399
1441
  pendingAsks = r.asks.map(a => ({ ...a, state: deepFreeze(a.state) }));
1400
1442
  if ([opts.restoreEpoch].some((e) => e !== undefined && (!Number.isSafeInteger(e) || e <= 0))) throw new Error('restored epoch is invalid');
1443
+ if(r.maps){
1444
+ mapRevision=r.maps.current;pendingMap=r.maps.pending;
1445
+ if(mapRevision){const p=prepareMapReplacement(mapRevision[1],c.map);mapRevision=Object.freeze([mapRevision[0],p.raw]);currentMap=p.map;currentMapCells=p.cells;mapCells=p.cells;}
1446
+ if(pendingMap){const p=prepareMapReplacement(pendingMap[1],c.map);pendingMap=Object.freeze([pendingMap[0],p.raw]);pendingGeometry=p.map;pendingMapCells=p.cells;mapCells=currentMapCells+pendingMapCells;}
1447
+ moveGeometry=currentMap;
1448
+ }
1401
1449
  tick = r.tick; epoch = opts.restoreEpoch ?? r.epoch; rng = r.rng; nextId = r.nextId; seq = r.seq; overAt = r.overAt; playing = r.match[0]; restartAt = r.match[1]; trips = r.trips;
1402
1450
  endAsked = r.intent?.[0] === 1; finishing = r.intent?.[1] === 1; restartAsked = r.intent?.[2] === 1;
1403
1451
  round = { n: r.round[0], phase: r.round[1] === 1 ? 'live' : 'over', endsAt: r.round[2], startedAt: r.round[3] };
@@ -1416,7 +1464,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1416
1464
  });
1417
1465
  areas = r.areas.map((a: any[]) => ({ from: a[0], fromId: a[1], seq: a[2], shape: a[3], ev: a[4], data: deepFreeze(coerceFields(c.events[a[4]] ?? [], a[5], dims)) }));
1418
1466
  ops = r.ops as typeof ops;
1419
- if(hasColliders)collisionState();
1467
+ if(hasColliders||mapRevision||pendingMap)collisionState();
1420
1468
  } else startMatch();
1421
1469
 
1422
1470
  function decisionResult(value: unknown): Record<string, unknown> {
@@ -1501,8 +1549,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
1501
1549
  const list = [...ents.values()].map((e) => packEntity(e.kind, e, dims));
1502
1550
  // Include all three components of position, velocity and heading in a 3D snapshot.
1503
1551
  snapCells = packed.n + (dims === 3 ? 13 : 10) * list.length;
1504
- const collision = hasColliders ? collisionState() : null;
1505
- if (collision) snapCells += 3 + collision[2].length * 8;
1552
+ const collision = hasColliders||mapRevision||pendingMap ? collisionState() : currentMap.wrap ? [0,0,[],{wrap:currentMap.wrap}] : null;
1553
+ if (collision) snapCells += 3 + collision[2].length * 8 + mapCells;
1506
1554
  return [[round.n, round.phase === 'live' ? 1 : 0, round.endsAt], list, ...(collision ? [collision] : [])];
1507
1555
  },
1508
1556
  shared: () => packFields(c.shared, shared, dims),
package/rules/host.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { periodicDelta } from './topology.mjs';
1
2
  import { stageTimings } from './timing.mjs';
2
3
  /*
3
4
  * host.ts — the host runtime: a rules module playing the host's part on the wire.
@@ -675,7 +676,7 @@ export function createHost(o: HostOptions): Host {
675
676
  const watching = all.filter((_, n) => { const a = x.list[n][3]; return !a || a[0] >= 0; });
676
677
  for (const seat of present.keys()) {
677
678
  const l = bodies.get(seat);
678
- const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(a[2][0] - l.pos.x, a[2][1] - l.pos.y, a[2][2] - l.pos.z) <= a[0]); });
679
+ const mine = !l ? watching : all.filter((_, n) => { const a = x.list[n][3]; return !a || a[1].includes(l.id) || (a[0] >= 0 && a[2] !== null && Math.hypot(periodicDelta(l.pos.x,a[2][0],o.compiled.map.wrap?.x), periodicDelta(l.pos.y,a[2][1],o.compiled.map.wrap?.y), periodicDelta(l.pos.z,a[2][2],o.compiled.map.wrap?.z)) <= a[0]); });
679
680
  if (mine.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, mine], to: seat });
680
681
  }
681
682
  if (watching.length) o.send({ t: 'ev', from: null, k: 'fx', d: [x.tick, watching], to: -1 });