@jgengine/core 0.19.0 → 0.22.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.
Files changed (266) hide show
  1. package/CHANGELOG.md +364 -0
  2. package/dist/ai/decisionGraph.d.ts +2 -0
  3. package/dist/ai/decisionGraph.js +6 -0
  4. package/dist/ai/flock.d.ts +1 -1
  5. package/dist/ai/flock.js +7 -4
  6. package/dist/anim/animGraph.d.ts +33 -2
  7. package/dist/anim/animGraph.js +200 -12
  8. package/dist/anim/locomotionGraph.d.ts +11 -3
  9. package/dist/anim/locomotionGraph.js +25 -15
  10. package/dist/audio/audioFalloff.d.ts +4 -0
  11. package/dist/audio/voiceAllocator.d.ts +46 -0
  12. package/dist/audio/voiceAllocator.js +76 -0
  13. package/dist/combat/abilityKit.d.ts +19 -0
  14. package/dist/combat/abilityKit.js +40 -4
  15. package/dist/combat/death.d.ts +4 -3
  16. package/dist/combat/death.js +3 -3
  17. package/dist/combat/magazine.d.ts +9 -0
  18. package/dist/combat/magazine.js +24 -2
  19. package/dist/combat/projectiles.d.ts +94 -2
  20. package/dist/combat/projectiles.js +282 -61
  21. package/dist/combat/regenShield.d.ts +14 -0
  22. package/dist/combat/regenShield.js +23 -7
  23. package/dist/crafting/recipeCatalog.d.ts +16 -0
  24. package/dist/crafting/recipeCatalog.js +55 -0
  25. package/dist/economy/auctionBook.d.ts +1 -1
  26. package/dist/economy/auctionBook.js +11 -1
  27. package/dist/economy/listingBook.d.ts +1 -1
  28. package/dist/economy/listingBook.js +11 -1
  29. package/dist/economy/resourceLedger.js +3 -3
  30. package/dist/economy/shopStock.js +7 -6
  31. package/dist/economy/techTree.js +7 -6
  32. package/dist/economy/unlockPoints.js +3 -6
  33. package/dist/editor/commandApply.js +89 -16
  34. package/dist/editor/commands.d.ts +38 -1
  35. package/dist/editor/commands.js +320 -3
  36. package/dist/editor/creatorStorage.d.ts +59 -0
  37. package/dist/editor/creatorStorage.js +164 -0
  38. package/dist/editor/document.d.ts +4 -0
  39. package/dist/editor/document.js +116 -17
  40. package/dist/editor/index.d.ts +3 -0
  41. package/dist/editor/index.js +1 -0
  42. package/dist/editor/liveSync.d.ts +4 -1
  43. package/dist/editor/liveSync.js +26 -10
  44. package/dist/editor/materialAuthoring.d.ts +14 -0
  45. package/dist/editor/materialAuthoring.js +38 -0
  46. package/dist/editor/modelNodeVisibility.d.ts +11 -0
  47. package/dist/editor/modelNodeVisibility.js +29 -0
  48. package/dist/editor/movementCatalog.d.ts +90 -0
  49. package/dist/editor/movementCatalog.js +144 -0
  50. package/dist/editor/simulation.d.ts +60 -0
  51. package/dist/editor/simulation.js +202 -0
  52. package/dist/editor/staticPrefab.d.ts +17 -0
  53. package/dist/editor/staticPrefab.js +51 -0
  54. package/dist/editor/terrainMaterials.d.ts +10 -0
  55. package/dist/editor/terrainMaterials.js +13 -0
  56. package/dist/editor/types.d.ts +11 -0
  57. package/dist/game/cameraConfig.d.ts +3 -1
  58. package/dist/game/clipRoles.d.ts +2 -1
  59. package/dist/game/clipRoles.js +7 -3
  60. package/dist/game/contentValidation.d.ts +69 -0
  61. package/dist/game/contentValidation.js +118 -0
  62. package/dist/game/controlGate.d.ts +8 -0
  63. package/dist/game/controlGate.js +43 -6
  64. package/dist/game/defineGame.d.ts +32 -3
  65. package/dist/game/dialogueGraph.d.ts +20 -0
  66. package/dist/game/dialogueGraph.js +107 -0
  67. package/dist/game/events.d.ts +5 -0
  68. package/dist/game/keyValueStore.d.ts +17 -4
  69. package/dist/game/keyValueStore.js +35 -23
  70. package/dist/game/lootModifiers.d.ts +17 -0
  71. package/dist/game/lootModifiers.js +26 -0
  72. package/dist/game/narrativeAuthoring.d.ts +67 -0
  73. package/dist/game/narrativeAuthoring.js +122 -0
  74. package/dist/game/playableGame.d.ts +52 -20
  75. package/dist/game/quest.d.ts +15 -0
  76. package/dist/game/quest.js +209 -63
  77. package/dist/game/questCatalog.d.ts +50 -0
  78. package/dist/game/questCatalog.js +205 -0
  79. package/dist/game/saveStore.d.ts +6 -5
  80. package/dist/game/saveStore.js +57 -57
  81. package/dist/game/systemRuntime.d.ts +2 -2
  82. package/dist/game/systemRuntime.js +84 -33
  83. package/dist/gameplay.d.ts +3 -0
  84. package/dist/gameplay.js +3 -0
  85. package/dist/interaction/promptRegistry.d.ts +5 -4
  86. package/dist/interaction/promptRegistry.js +8 -2
  87. package/dist/inventory/inventoryModel.js +9 -6
  88. package/dist/inventory/shapedGrid.d.ts +48 -9
  89. package/dist/inventory/shapedGrid.js +69 -10
  90. package/dist/item/durability.js +4 -0
  91. package/dist/item/itemInstanceRegistry.d.ts +32 -1
  92. package/dist/item/itemInstanceRegistry.js +111 -7
  93. package/dist/item/itemgen.d.ts +1 -1
  94. package/dist/item/itemgen.js +1 -1
  95. package/dist/item/modularItem.js +7 -1
  96. package/dist/material/appearanceSignals.d.ts +53 -0
  97. package/dist/material/appearanceSignals.js +47 -0
  98. package/dist/material/hairCards.d.ts +47 -0
  99. package/dist/material/hairCards.js +136 -0
  100. package/dist/material/materialAsset.d.ts +162 -0
  101. package/dist/material/materialAsset.js +382 -0
  102. package/dist/meta/changelog.d.ts +1 -1
  103. package/dist/meta/changelog.js +348 -1
  104. package/dist/movement/characterController.d.ts +5 -2
  105. package/dist/movement/characterController.js +50 -23
  106. package/dist/movement/freeFlight.d.ts +2 -2
  107. package/dist/movement/freeFlight.js +3 -3
  108. package/dist/movement/movementModel.d.ts +24 -1
  109. package/dist/movement/movementModel.js +74 -7
  110. package/dist/movement/playerMovement.d.ts +28 -1
  111. package/dist/movement/playerMovement.js +425 -102
  112. package/dist/movement/poseState.d.ts +2 -0
  113. package/dist/movement/poseState.js +29 -1
  114. package/dist/movement/solidObstacles.d.ts +3 -1
  115. package/dist/movement/solidObstacles.js +8 -5
  116. package/dist/movement/terrainGrade.d.ts +13 -0
  117. package/dist/movement/terrainGrade.js +33 -0
  118. package/dist/movement/voxelController.d.ts +2 -2
  119. package/dist/movement/voxelController.js +3 -3
  120. package/dist/nav/navGrid.d.ts +19 -1
  121. package/dist/nav/navGrid.js +26 -10
  122. package/dist/nav/solidRoute.d.ts +50 -0
  123. package/dist/nav/solidRoute.js +144 -0
  124. package/dist/physics/ballisticSweep.d.ts +10 -3
  125. package/dist/physics/ballisticSweep.js +53 -29
  126. package/dist/physics/forceVolume.d.ts +41 -0
  127. package/dist/physics/forceVolume.js +88 -0
  128. package/dist/physics/kinematicVehicle.d.ts +8 -3
  129. package/dist/physics/kinematicVehicle.js +2 -2
  130. package/dist/physics/physicsWorldBackend.js +19 -3
  131. package/dist/physics/spatialGrid.d.ts +1 -1
  132. package/dist/physics/spatialGrid.js +8 -9
  133. package/dist/physics/vehicleDynamics.d.ts +3 -2
  134. package/dist/physics/vehicleDynamics.js +2 -2
  135. package/dist/physics/vehicleObstacles.d.ts +3 -2
  136. package/dist/physics/vehicleObstacles.js +29 -5
  137. package/dist/runtime/cameraDirector.d.ts +39 -2
  138. package/dist/runtime/cameraDirector.js +162 -0
  139. package/dist/runtime/commandDispatch.d.ts +14 -0
  140. package/dist/runtime/commandDispatch.js +19 -0
  141. package/dist/runtime/context/combat.d.ts +11 -4
  142. package/dist/runtime/context/combat.js +178 -11
  143. package/dist/runtime/context/deathLoot.d.ts +9 -8
  144. package/dist/runtime/context/deathLoot.js +16 -16
  145. package/dist/runtime/context/scene.d.ts +2 -0
  146. package/dist/runtime/context/scene.js +13 -21
  147. package/dist/runtime/descriptors/baseline.js +34 -3
  148. package/dist/runtime/descriptors/features.d.ts +2 -1
  149. package/dist/runtime/descriptors/features.js +18 -0
  150. package/dist/runtime/gameContext.js +79 -13
  151. package/dist/runtime/gameContextTypes.d.ts +14 -0
  152. package/dist/runtime/headlessRunner.d.ts +3 -1
  153. package/dist/runtime/headlessRunner.js +11 -11
  154. package/dist/runtime/hostedGameRunner.d.ts +24 -1
  155. package/dist/runtime/hostedGameRunner.js +156 -10
  156. package/dist/runtime/hostedWorldSession.d.ts +17 -5
  157. package/dist/runtime/hostedWorldSession.js +154 -16
  158. package/dist/runtime/hostedWorldStore.d.ts +2 -2
  159. package/dist/runtime/inputRecorder.d.ts +1 -1
  160. package/dist/runtime/inputRecorder.js +21 -1
  161. package/dist/runtime/inputSnapshot.d.ts +16 -4
  162. package/dist/runtime/inputSnapshot.js +31 -3
  163. package/dist/runtime/runtimeSave.d.ts +10 -6
  164. package/dist/runtime/runtimeSave.js +11 -5
  165. package/dist/runtime/simContext.d.ts +8 -3
  166. package/dist/runtime/simContext.js +7 -3
  167. package/dist/runtime/simSnapshot.js +2 -0
  168. package/dist/runtime/worldReplication.d.ts +2 -0
  169. package/dist/runtime/worldReplication.js +1 -1
  170. package/dist/scene/assetCatalog.d.ts +2 -0
  171. package/dist/scene/behaviorRuntime.d.ts +26 -5
  172. package/dist/scene/behaviorRuntime.js +263 -16
  173. package/dist/scene/behaviors.d.ts +30 -1
  174. package/dist/scene/behaviors.js +29 -0
  175. package/dist/scene/entityStore.d.ts +14 -3
  176. package/dist/scene/entityStore.js +45 -9
  177. package/dist/scene/mount.d.ts +9 -0
  178. package/dist/scene/mount.js +35 -0
  179. package/dist/scene/possession.d.ts +2 -0
  180. package/dist/scene/possession.js +21 -1
  181. package/dist/scene/sceneRaycast.d.ts +7 -0
  182. package/dist/scene/sceneRaycast.js +55 -0
  183. package/dist/scene/spatial.d.ts +20 -1
  184. package/dist/scene/spatial.js +94 -2
  185. package/dist/scene/vehicleSeat.d.ts +36 -2
  186. package/dist/scene/vehicleSeat.js +89 -2
  187. package/dist/stats/accumulatorMeter.d.ts +10 -0
  188. package/dist/stats/accumulatorMeter.js +6 -0
  189. package/dist/stats/eventMeter.d.ts +9 -2
  190. package/dist/stats/eventMeter.js +5 -2
  191. package/dist/store/defineStore.d.ts +3 -1
  192. package/dist/store/defineStore.js +3 -1
  193. package/dist/store/observableKeyedStore.d.ts +1 -1
  194. package/dist/store/observableKeyedStore.js +8 -6
  195. package/dist/time/simClock.d.ts +6 -2
  196. package/dist/time/simClock.js +5 -0
  197. package/dist/vfx/particleDirector.d.ts +58 -6
  198. package/dist/vfx/particleDirector.js +113 -22
  199. package/dist/vfx/particles.d.ts +68 -5
  200. package/dist/vfx/particles.js +284 -32
  201. package/dist/visibility/assetStreaming.d.ts +21 -3
  202. package/dist/visibility/assetStreaming.js +108 -24
  203. package/dist/visibility/settings.d.ts +8 -2
  204. package/dist/visibility/settings.js +3 -0
  205. package/dist/visibility/visibilitySystem.js +1 -0
  206. package/dist/work/expedition.d.ts +110 -0
  207. package/dist/work/expedition.js +195 -0
  208. package/dist/work/staffedStation.d.ts +29 -0
  209. package/dist/work/staffedStation.js +27 -0
  210. package/dist/world/authoredAnimation.d.ts +38 -0
  211. package/dist/world/authoredAnimation.js +99 -0
  212. package/dist/world/authoredObjects.d.ts +24 -4
  213. package/dist/world/authoredObjects.js +43 -16
  214. package/dist/world/authoredSimulation.d.ts +74 -0
  215. package/dist/world/authoredSimulation.js +302 -0
  216. package/dist/world/authoredWeather.d.ts +120 -0
  217. package/dist/world/authoredWeather.js +314 -0
  218. package/dist/world/buildingKit.d.ts +8 -3
  219. package/dist/world/buildingKit.js +21 -8
  220. package/dist/world/environmentForces.d.ts +21 -0
  221. package/dist/world/environmentForces.js +42 -0
  222. package/dist/world/features.d.ts +3 -3
  223. package/dist/world/footprintGrid.d.ts +55 -0
  224. package/dist/world/footprintGrid.js +201 -0
  225. package/dist/world/grassKind.d.ts +10 -0
  226. package/dist/world/grassKind.js +22 -0
  227. package/dist/world/pathTerrain.d.ts +1 -1
  228. package/dist/world/terraform.d.ts +2 -2
  229. package/dist/world/terraform.js +46 -18
  230. package/dist/world/weather.d.ts +17 -0
  231. package/dist/world/weather.js +116 -20
  232. package/dist/world/worldSolids.d.ts +11 -0
  233. package/dist/world/worldSolids.js +112 -1
  234. package/dist/world.d.ts +5 -5
  235. package/dist/world.js +4 -4
  236. package/package.json +1 -1
  237. package/skills/ce-handoff/SKILL.md +1 -1
  238. package/skills/game-design/SKILL.md +2 -0
  239. package/skills/jgengine/SKILL.md +23 -2
  240. package/skills/jgengine/api.md +28 -17
  241. package/skills/jgengine/recipes/minimal-game.md +7 -2
  242. package/skills/jgengine-combat/SKILL.md +8 -2
  243. package/skills/jgengine-combat/api.md +22 -10
  244. package/skills/jgengine-combat/recipes/portable-runtime-state.md +60 -6
  245. package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +37 -3
  246. package/skills/jgengine-combat/reference.md +24 -2
  247. package/skills/jgengine-gameplay/SKILL.md +63 -0
  248. package/skills/jgengine-gameplay/api.md +131 -37
  249. package/skills/jgengine-gameplay/capabilities.md +68 -0
  250. package/skills/jgengine-gameplay/recipes/device-save-status.md +46 -0
  251. package/skills/jgengine-gameplay/recipes/expeditions.md +108 -0
  252. package/skills/jgengine-gameplay/recipes/narrative-authoring.md +76 -0
  253. package/skills/jgengine-gameplay/recipes/paged-item-storage.md +101 -0
  254. package/skills/jgengine-gameplay/recipes/quest-authoring.md +63 -0
  255. package/skills/jgengine-gameplay/recipes/shaped-inventory.md +50 -0
  256. package/skills/jgengine-gameplay/reference-content-validation.md +74 -0
  257. package/skills/jgengine-gameplay/reference-systems.md +119 -0
  258. package/skills/jgengine-world/SKILL.md +12 -3
  259. package/skills/jgengine-world/api.md +201 -58
  260. package/skills/jgengine-world/capabilities.md +116 -3
  261. package/skills/jgengine-world/recipes/character-animation.md +31 -8
  262. package/skills/jgengine-world/recipes/controls.md +4 -0
  263. package/skills/jgengine-world/recipes/npc-senses.md +12 -0
  264. package/skills/jgengine-world/recipes/vehicle-feel.md +3 -1
  265. package/skills/jgengine-world/reference.md +167 -5
  266. package/skills/jgengine-world/visibility.md +28 -1
@@ -28,6 +28,21 @@ export const overworld = world({
28
28
 
29
29
  ## Legacy world features
30
30
 
31
+ Authored terrain paint carried by `environmentContentFromDocument(document).sculpt` renders on
32
+ the normal environment ground. Single surface IDs use `TERRAIN_MATERIAL_COLORS` from
33
+ `@jgengine/core/editor/terrainMaterials`; a layer's `tint` replaces that default color. Layer
34
+ weights are multiplied by `opacity`, normalized, and blended in linear color space, matching
35
+ the editor. Unpainted cells retain the descriptor palette. Grass roots and soil patch borders
36
+ sample the same authored colors. Paint changes color, never ground height or collision.
37
+ Descriptor-wide terrain detail still overlays the painted ground. Per-layer `roughness`,
38
+ `tiling` and `triplanar` remain serialized authoring metadata; they do not select or configure
39
+ separate runtime materials.
40
+
41
+ Custom ground consumers can use `createTerrainPaintColorSampler` from `@jgengine/shell/terrain`
42
+ with a `TerrainPaintSource` (an editable terrain's layer/surface/weight channels). The returned
43
+ `TerrainPaintColorSampler` writes a linear `THREE.Color` and returns whether the point is painted;
44
+ false leaves the caller's fallback color unchanged. The normal ground wires this automatically.
45
+
31
46
  Descriptors from `@jgengine/core/world/features` — legacy code-declared world shapes kept for
32
47
  existing games. Do not start new worlds here; `environment()` is now the consumption target for
33
48
  editor/preset-written scene data (`environmentContentFromDocument`), not a world definition:
@@ -43,6 +58,8 @@ editor/preset-written scene data (`environmentContentFromDocument`), not a world
43
58
 
44
59
  `biomes`/`voxel`/`plots`/`tilemap` share a `WorldGridConfig` (`cells?: WorldGridCell[]`, `cellSize?`, `baseHeight?`, `defaultColor?`) — a `WorldGridCell` is `{ x, z, height?, color? }`, one extruded box per cell. `resolveGridInstances(config)` (`@jgengine/core/world/gridInstances`) is the pure cell→instance math (position, scale, color per cell); the shell renders the result as a single `THREE.InstancedMesh` **automatically whenever `PlayableGame.environment` is unset and `game.world` is one of these four grid kinds** — no manual render wiring for a cell-based world, same auto-render convention as `environment()` worlds.
45
60
 
61
+ `GrassField` keeps its seeded tuft attributes and blade-budget prefix while partitioning a patch into at most 64 shared-template draws. Each render camera rejects offscreen chunks using conservative sampled-height/blade/wind bounds, and rejects a chunk by distance only when all transformed tuft roots are beyond the fade end. Shadow passes restore their own budget rather than inheriting a main-camera rejection. `distanceFade: false` (or an empty/reversed band) disables distance rejection; `frustumCulled: false` disables chunk frustum rejection. Material colors, wind and caller mesh transforms stay caller-owned. Fully faded blades now collapse width and wind displacement with height; fading blades narrow, and near blades with `keep = 1` retain their appearance. Chunk geometry/template resources and the shared material dispose on replacement/unmount.
62
+
46
63
  `terrain()`'s `material` (a named palette — `"grass" | "sand" | "snow" | "rock" | "ash"`, resolved via `resolveTerrainPalette`/`TERRAIN_MATERIAL_PALETTES` in `world/terrain`) sets the default low/high/waterline colors; `colors: { low?, high?, waterline? }` overrides any of them field-by-field, and `segments` tunes the render mesh's subdivision. `biomeBands: BiomeBand[]` (`{ z, fade?, material?, colors? }`) cross-fades the **ground palette along the world's z axis** — ordered zones (vale → marsh → peaks) that blend across a `fade`-wide window (default 64) centered on each boundary; it's the linear counterpart to the radial `materialRegions` (which paint on top; each region is a `TerrainCircleRegion` (default), `TerrainPolylineRegion` (roads/rivers), or `TerrainRectRegion` (rotatable districts), sharing a `TerrainRegionStyle`). Pure/testable via `createTerrainPaletteSampler`/`createBiomeBandSampler` in `world/terrain`; the shell renders it through the same per-vertex `paletteAt` seam as regions, no extra wiring. (Per-zone **fog/sky** cross-fade isn't wired yet — three.js fog is scene-global — so bands drive ground color today.) `flatten: TerrainFlattenMask[]` (`{ center, radius, height?, falloff? }`) carves explicit flat circles into the noise field independent of pads — building foundations, spawn circles, roads — blending back to the noise height over `falloff` (default `radius * 0.5`). `detail: TerrainDetailConfig` (`{ rockColor?, sandColor?, snowColor?, rockSlopeStart?, snowHeight?, waterLevel?, detailScale?, macroScale?, roughness?, strength? }`) swaps the flat vertex-colour ground for a procedural noise shader (`@jgengine/shell`'s `createTerrainDetailMaterial`, defaults via `resolveTerrainDetail`): it keeps the biome-tinted base and blends noise-broken **rock by slope**, **sand by waterline**, and **snow by height** over it — textured-reading terrain with no image assets, full PBR (lit/shadowed/fogged), composing with the post-processing chain. Omit for the flat look. `detail.material: { maps, repeat?, strength? }` (`TerrainDetailMaterialConfig`) is the real-texture seam: `maps` is `buildMaterialCatalog({ basePath }).resolve("material/grass")!.maps` from `@jgengine/assets` (or any URLs with the same `{ color, normal, roughness, ao, displacement }` shape) — `resolveTerrainDetail` fills `repeat` (world units per tile, default 4) and `strength` (blend 0..1, default 1), and the shell's `createTerrainDetailMaterial(detail, textures)` tiles the loaded color/roughness/ao/normal by world position and blends them over the same procedural rock/sand/snow result, never replacing it. The shell resolves the map URLs to `THREE.Texture`s itself (`useTexture`/`useLoader`, e.g. in the game's `environment` component) — `core` only ever sees the URL strings. Detail noise is coordinate-stable at any world position — the fragment hash is integer/position-based, so `detailScale` keeps full fine detail far from origin (a playfield at `|x|≈550` reads the same as one near origin; the old sin-hash flattened past `worldPos/scale ≳ 100`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, radius?, hazeStrength?, sunGlowStrength?, fog? })` — `preset: "day" | "dusk" | "night"` (default `"day"`) is the static look; `timeOfDay: true` instead drives sun position, sky colors, and fog from the world clock's `calendar().dayFraction` every frame (`@jgengine/shell`'s `TimeOfDayDaylight` mounts this automatically for an `environment()` world with `sky` set — no per-game render wiring). The sky dome renders a bright HDR sun disc + warm glow (aligned to the sun light, so it blooms through a `postProcessing` chain), a mid-sky procedural cloud band, and a horizon haze — no HDRI assets; richer than a flat gradient with zero config. The dome **follows the camera every frame** (XZ), so it never leaves the viewer — but its `radius` (default 260) still has to clear your `fog.far` and any far scenery; a playfield sited far from the origin (or with distant landmarks) sets `radius` up to a few thousand so nothing pokes through the shell. `hazeStrength` (default 0.5) sizes the dusty horizon band and `sunGlowStrength` (default 1) the bloom around the sun disc — leave both unset for the stock look, raise/lower to dial a dusk/dust-storm horizon (the-robots uses `radius: 2600, hazeStrength: 0.62, sunGlowStrength: 0.6`). No game needs its own sky-dome mesh; configure `sky()` instead. **Gotcha:** `sunIntensity` / `ambientIntensity` are honored only on the **day** keyframe (`daylightCycle` builds dusk/night/dawn from fixed constants). Raising intensities under `preset: "dusk"` or `"night"` does nothing — use `preset: "day"` plus warm `horizonColor`/`zenithColor` when the first screenshot must be bright and readable (see `jgengine`'s first-shot art recipe).
47
64
 
48
65
  **Look presets.** Unset `look` is `"neutral"`; `defineGame({ look: "cinematic" | "photoreal" | "comic" | ... })` gives a 3D game a real sky + shadow-casting sun/hemisphere rig + a tuned post stack. The preset **defers to a world that declares its own `sky`** — an `environment({ sky })` world keeps exactly the dome/time-of-day/lighting it configured; the preset only supplies a sky (and the post stack) to worlds that have none. So `sky()` tuning below still owns the look for scenes that set it. Full rig + resolver: `jgengine-ui` → "Rendering".
@@ -58,6 +75,8 @@ external-store walkthrough are owned by the
58
75
 
59
76
  ### Query primitives (renderer-free, for gameplay)
60
77
 
78
+ `createPromptRegistry` (`@jgengine/core/interaction/promptRegistry`) owns contextual interaction prompts. Register or update their display and command as gameplay changes; edits to the selected prompt notify subscribed HUDs immediately. Equivalent edits, edits to inactive prompts and unchanged frame resolutions stay silent. Position, priority and radius edits publish the selected prompt's new data but retain selection until the next `resolve(playerPosition)`. Subscriber reads through `active()` see committed content. `useInteractionPrompt` and `InteractionPrompt` consume these notifications without requiring player movement.
79
+
61
80
  Pure `@jgengine/core` functions so gameplay reads the same world the shell renders — no three.js needed:
62
81
 
63
82
  | Primitive | Answers |
@@ -71,6 +90,7 @@ Pure `@jgengine/core` functions so gameplay reads the same world the shell rende
71
90
  | `createRegionField({ regions })` → `RegionField` | `sampleRegion(x,z)` blends content-agnostic biomes by nearest selector — height + `tint`/`water`/`fog`/`speedMultiplier` + opaque `data`. Extends `TerrainField`, so it ground-snaps too |
72
91
  | `scatterItems(field, area, layersFor)` → `ScatterInstance[]` | Region-driven content scatter — density per region, grounded, above-water/slope-aware. `pickWeighted` for weighted rolls. (vs `scatter`'s pure geometric points) |
73
92
  | `resolveScatterRegion(region, terrain?)` / `resolveScatter(doc, terrain?)` | Deterministic instanced placements for a **freehand polygon** scatter region (the editor's foliage lasso): `density`/`minSpacing`/`seed`, `minScale`/`maxScale`, weighted `palette`, `maxSlope`/`minHeight`/`maxHeight` masks, `edgeFalloff` feathering, `alignToNormal`. Regions ride editor paths of `kind` `SCATTER_PATH_KIND` — `scatterRegionFromPath`/`readScatterRules`/`readScatterPalette` build one from a path's meta, `scatterRegionEstimate` gives the live count readout, `resolveScatter` walks every scatter path in a document. Polygon helpers: `pointInPolygon`, `distanceToPolygonEdge`, `polygonArea`, `polygonBounds`, `isScatterPath`. Same seed → same field; GPU-instanced, never one node per instance. |
93
+ | `readAuthoredAnimation(doc, markerId, defaults?)` / `createAuthoredAnimationReader(getDocument, markerId, defaults?)` | Saved character animation from `marker.meta.animation` for game-owned dynamic actor mappings. Valid authored objects replace defaults; `auto`/`none` and partial roles remain explicit. Omitted or malformed overrides return the same supplied default with located repair diagnostics. The reader caches by immutable document identity; a thin `ModelConfig.animation` getter reads its result. Games retain actor-to-marker association, rig identity and combat graph policy. |
74
94
  | `resolveAuthoredObjects(doc)` / `placeAuthoredObjects(store, objects, sampleHeight, opts?)` / `placeAuthoredObjectsFromDocument` / `markerCatalogId(marker)` / `markerAnimation(marker)` | Catalog mesh props from editor markers: every marker with first-class `catalogId` or `meta.catalogId` → `{ catalogId, x, z, rotationY, instanceId, verticalOffset, animation? }[]`. A placement's authored `marker.meta.animation` (#1274) is read by `markerAnimation`, carried as the optional `AuthoredObject.animation`, and threaded through `placeAuthoredObjects` → `store.place` onto the placed object's `ModelConfig.animation`, so a per-placement rig override (role→clip, auto/none, speeds, fade, one-shots) applies at play time (#1276); absent, animation stays catalog-resolved. `placeAuthoredObjects` grounds on `sampleHeight` and calls `store.place` (WorldScene renders via `objectModels`). Shell: `<AuthoredObjects document field />` or `<AuthoredScene placeObjects />`. Never hand-loop `document.markers` for prop placement. |
75
95
  | `buildingIndex(district)` → `BuildingIndex` | `at`/`within`/`nearest`/`isInside`/`blockers` over a generated district — placement avoidance, pathfinding |
76
96
 
@@ -80,11 +100,21 @@ Pure `@jgengine/core` functions so gameplay reads the same world the shell rende
80
100
 
81
101
  Renderers for these descriptors live in `@jgengine/shell` (`shell/terrain`, `shell/water`, `shell/weather`, `shell/structures`).
82
102
 
103
+ `defineBuildingKit({ materialAssets: document.materialAssets, parts, slots? })` consumes the existing editor material library. Each model variant may carry `materialAssignments` from `authoredMaterialAssignments(marker.meta)` (`editor/materialAuthoring`): mesh name, native material-slot name and slot index intersect, with explicit per-assignment surface overrides. Author the reusable facade/roof/window materials and assignments in the editor, save/reopen the document, then bind its data to caller-selected models. Unassigned imported slots/maps and physical features survive. The older palette/kit `material: { color, maps, repeat, roughness, metalness }` path still handles broad tiled surfaces; broad tint/surface edits run before named assignments. Named kit assignments reject authored height/displacement maps before loading or allocating resources because instance bounds use native geometry. Unused height assets elsewhere in the document and existing imported maps remain valid; individual model rendering retains its existing map support. Kits do not supply palettes, building presets, placement or navigation policy.
104
+
105
+ Named kit styles share one owned model preparation per resolved appearance, then the existing spatial chunks instance its borrowed geometry. Only referenced material IDs load texture views; each style owns its materials and sampler/UV views, retaining cached models and maps. Different worlds may reuse IDs with different document asset values. Material identity includes selectors, overrides and referenced asset values; unrelated library edits do not create a new style. Retiring a style releases its derived materials and texture views, leaving other styles and cached geometry/maps alive.
106
+
107
+ `InstancedBuildings` spatially chunks palette boxes and fitted kit meshes in 64-unit local XZ cells; compact batches stay together. Material groups, tint/surface buckets and shadow casting are preserved. Environment structures and authored city facade details adopt this automatically. The shell uses the resolved `defineGame.graphics` tier's `drawDistance`; `visibility.culling.defaultMaxRenderDistance` and `visibility.scene.maxRenderDistance` override it. Distance gating honors visibility/distance disable switches, scene bypasses and hysteresis; custom scene callbacks and split-screen conservatively bypass this gate. Outside the shell the distance is unlimited. Bounds include actual geometry, kit fit/source transforms and transformed parents; Three tests each chunk against each render/shadow camera. This does not chunk city massing or change asset residency. Measure matched `debug_perf_reset` / `debug_snapshot` draw and triangle counters before claiming a game improvement; extra visible chunks can increase draw calls.
108
+
109
+ The 3D shell batches authored catalog props automatically through `objectModels` and the live `editorLayers` document. More than eight compatible placements per catalog/model and 24 m cell share instanced draw sources. Each source preserves GLB mesh transforms, material groups, model normalization and shadow mode; bounds and triangle measurement still feed object colliders. Visibility removes culled ids from the submitted instance set, and pointer hits resolve the compacted slot to the original object id. Custom/interactive props, visual/style overrides, animations, rigs, compositions, material maps, transparent materials and mirrored transforms retain individual renderers. Live visual/state/animation or catalog interaction changes trigger fallback; move and rotation update instance matrices without changing the document.
110
+
83
111
  ### Environment fields, weather hooks & realm composition
84
112
  Renderer-free survival/environment primitives that extend the world query layer — meters, spawn gating, and damage-in-sunlight read the same world the shell renders, all ticking on game-time `dt`.
85
113
  - **Environment field** (`world/envField`): `createEnvironmentField({ dayLength, baseTemperature, nightDrop, altitudeLapse, terrain, rain, occluders, heatSources, ambientFloor, temperatureAt })` → `EnvironmentField`. Sample **temperature**, **wetness**, **lightExposure** (direct sun/sky), and **ambientLight** (spawn gating) at any `(x, z, time)` — `sample(x, z, time, y?)` returns all four plus `sheltered`. Occluders (roofs/canopy) shade sun and shelter from rain; heat sources (campfires) warm nearby positions; `sunElevation(time)` drives the day cycle. Sun damages a vampire, cold forces campfires, low ambient light spawns mobs — the field answers "am I in sun vs. shade / cold vs. warm / dark vs. lit". Pure and instantaneous; stateful build-up belongs to a decay meter reading the field.
114
+ - **Authored weather** (`world/authoredWeather`, `world/authoredSimulation`): author `EditorDocument.simulation.weather` with named `profiles`, `initialProfileId`, absolute `schedule` entries (`atSeconds`, `profileId`, `transitionSeconds`), ambient `wind`, and local `zones`. `createAuthoredWeather(config).sample(x,z,timeSeconds)` is pure; overlapping transitions remain continuous, later weather zones blend over earlier ones, and radial/local wind adds to ambient wind. All lists are limited to 256 entries. Distances use metres; wind is metres/second along world X,Z; Y points up. `ctx.environment.sample(x,z)` uses `ctx.time` as the sole authority. The context advances `createAuthoredSimulation` once per simulation step; games must not tick it again. `ctx.environment.appearanceAt(surfaceId,[x,y,z])` supplies wind `[x,0,z]` in metres/second, normalized wetness/snow/exposure/fire heat, temperature offset in °C, and game seconds to material/terrain adapters. `ctx.environment.exposureAt(x,y,z)` queries authored `shelter` volumes; `watch({id,x,z,y?,temperature?})` retains bounded surface wetness/snow/heat in runtime snapshots, and `forget(id)` releases a surface. `createWeatherExposure` exposes `snapshot`/`restore`/`retune`, injected storage, and normalized accumulation rates per second. `createWeatherEnvironmentField` connects scheduled rain and temperature offsets to the existing environment query field. The shell's `AuthoredWeatherLayer`/`WeatherLayer.sample` consume that authority with fixed precipitation pools, absolute shader time and pooled ground ripples. Visual clipping uses the nearest 32 shelters and reports when the authored list exceeds that budget; gameplay keeps all shelters. Ripples query at most 256 ground/exposure samples per frame (64 by default). `LightningStrike` accepts `timeSeconds` plus `startedAt` for deterministic pause/replay. The ambient/local wind composition follows [Unity Wind Zones](https://docs.unity3d.com/Manual/class-WindZone.html); shared game-to-effect payloads follow the principle described in [Niagara Data Channels](https://dev.epicgames.com/documentation/en-us/unreal-engine/niagara-data-channels-overview).
86
115
  - **Weather → gameplay** (`world/weather`): `resolveWeather(state, table)` turns a `WeatherState { kind, intensity }` into concrete `ResolvedWeather` (`grip`, `visibility`, `structureDamage`, `chill`, `ignition`, `spread`) via a game-owned `WeatherModifierTable` — multipliers interpolate from neutral by intensity, rate effects scale linearly. Read `grip`/`visibility` in movement and AI, `structureDamage` on a building tick.
87
- - **Fire spread** (`world/weather`): `createFireGrid({ cols, rows, cellSize, origin, fuelAt, spreadRate, burnRate, wind, windBias })` → `FireGrid` is a **coarse cellular** propagation (not a fluid solver): `ignite(x, z)` / `igniteCell(col, row)`, then `step(dt, { spread, wetnessAt })` transfers heat to neighbours biased by wind, consumes fuel (`unburnt → burning → burnt`), and honours firebreaks (zero-fuel cells) and rain/wetness suppression. `resolveWeather(...).spread` feeds the step; `@jgengine/shell/weather` `FireSpreadLayer` renders the burning/scorched cells.
116
+ - **Environment → appearance** (`material/appearanceSignals`): `resolveMaterialAppearance(sample, baseline, policy)` returns sparse, deterministic physical edits from an immutable resolved dry baseline. `sampleMaterialAppearance(field, { x, z, y?, time }, baseline, policy)` takes exactly one environment sample; callers bound their selected targets and update frequency. Wet endpoints are explicit roughness, linear-albedo retention, sheen and clearcoat choices; unrequested maps, coverage, transmission and imported slots survive. Wetness already includes shelter and is independent of sunlight. On an owned material, use `captureMaterialAppearanceBaseline`, `applyMaterialAppearanceSignals` and `restoreMaterialAppearanceBaseline` from `@jgengine/shell/render/materialAppearanceSignals`; capture after assignment and recapture after authored edits. Sheen/clearcoat require a declared, already physical material, and updates allocate no GPU resources. `cardBacklightExposure` only attenuates the existing hair-card approximation using sky exposure; it is neither precise per-light visibility nor a shadow or scattering model, and should not duplicate an already adequate shadow result. It changes no scene exposure. Cloning an already assigned card material keeps its compiled backlight uniform local to each clone; capture and restore each instance's baseline independently. The backlight adapter composes at Three's physical direct-light function without replacing its native diffuse/specular response; a renderer revision without that hook fails explicitly with its revision. Wet accumulation/drying and `WindField.atPoint(x,z,time)` motion belong to separate simulation/geometry consumers; this adapter creates no groom or cloth motion.
117
+ - **Fire spread** (`world/weather`): `createFireGrid({ cols, rows, cellSize, origin, fuelAt, spreadRate, burnRate, wind, windBias })` → `FireGrid` is a **coarse cellular** propagation (not a fluid solver): `ignite(x, z)` / `igniteCell(col, row)`, then `step(dt, { spread, wetnessAt, wind, windAt })` transfers heat to neighbours biased by wind, consumes fuel (`unburnt → burning → burnt`), and honours firebreaks (zero-fuel cells) and rain/wetness suppression. `resolveWeather(...).spread` feeds the step; `windAt(x,z)` can sample authored local wind live. `snapshot()`/`restore()` round-trip fuel, heat and burning counts; `retune()` changes rates and prevailing wind without recreating the grid. Allocation is bounded by `maxCells` (65536 by default), and the heat workspace is reused. `@jgengine/shell/weather` `FireSpreadLayer` renders the burning/scorched cells; `extinguish(x,z)`/`extinguishCell(col,row)` cools a burning cell and preserves remaining fuel. Opt into rain cooling with `extinguishRate` (heat units/second at full wetness). Authored emitters with `fireAreaId` spawn at their authored position only while that area burns; their existing particles finish naturally, and authored `active:false` stays disabled. pass `timeSeconds: () => ctx.time.now()` for paused/restored flame animation.
88
118
  - **Realm composition** (`world/realm`): `composeRealm(base, cards)` assembles a played instance at runtime from a deck of modifier **cards** (Nightingale realm cards) — a `major` card is the biome base, `minor` cards layer environment param overrides, a `WeatherState`, and spawn-table edits (`set`/`add`/`scale`/`remove`). The result recomposes both the environment (into a sampleable field via `composed.environmentField(extra?)`) and the `spawnTable`, and depends on the weather hooks above to turn its `weather` into gameplay modifiers.
89
119
  - **Gameplay visibility / fog-of-war** (`world/visibilityField`, distinct from render culling in `core/visibility`): `createVisibilityField({ bounds, cellSize, memory })` → `VisibilityField` tracks per **viewer group** whether each grid cell is `hidden`, `remembered`, or `observed`. Each update the caller feeds a group's observed cells — `observe(group, cells)` or `observeCircle(group, x, z, radius)`, unioning multiple sources via `cellsInRadius(...out)` — and gets back a bounded `VisibilityDelta` (never a full-grid scan). Terrain memory is injected, not baked: `{ kind: "permanent" }` keeps last-known terrain (RTS explored map, generalizing single-group `world/fog`), `"none"` is pure line-of-sight, `{ kind: "decay", updates }` ages memory out. Day/night, height, stealth, and sensor rules stay the caller's job — they decide which cells count as observed. `visibleTo(group, entities, positionOf)` is the authoritative disclosure filter: it returns only entities in cells the group currently **observes**, so a unit that left view (or a rival group's units) never leaks into a client's replica — enemy re-hides while remembered terrain stays. `toState()`/`restore` round-trips sparsely; `cells(group)` gives dense codes for minimap draw.
90
120
  ### Survival meters, moodles & multi-region health
@@ -109,23 +139,63 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
109
139
  | `createPlotPermissions({ plotId, ownerId, guildId? })` + `createContributionPool(goal)` | Per-plot/guild edit authority (`canEdit`/`canView`, `grant`/`revoke` `BuildRole`, guild inheritance) for co-op building, plus a pooled-resource contribution model (`contribute` caps at the goal, reports overflow, `isComplete`, per-contributor totals). |
110
140
  | `createFootprintGrid({ cellSize? })` (`world/footprintGrid`) | Multi-cell footprint occupancy/reservation on a shared build grid — `cellsFor(origin, footprint, quarterTurns?)` derives the cells, `reserve(id, kind, cells)`/`release(id)`/`isFree`/`occupantAt`/`kindAt` hold the persistent claim `placementController`'s ghost preview doesn't. `footprintObstacles(grid)` bridges live reservations into `world/placement`'s `PlacementRules.obstacles` unchanged — no separate obstacle bookkeeping. `boundaryNeighbors(grid, cells)`/`hasValidAdjacency(grid, cells, accepts, requireConnection?)` validate connective-piece adjacency (roads/pipes/belts): every touching occupied neighbor must satisfy `accepts(kind)`, and `requireConnection` demands at least one (a road segment with nothing to connect to is invalid). |
111
141
 
142
+
143
+ `world/footprintGrid` also provides pure region pooling: `canMerge(a, b, budget?)`
144
+ and `mergeFootprints(a, b, id, budget?)` require matching caller-owned kind/tier,
145
+ cardinal contact, unique disjoint safe-integer cells and distinct source identities.
146
+ A `MergedRegion` retains detached `contributions` with each original id, cells and
147
+ finite nonnegative capacity. Its capacity is the ordered JavaScript-number sum;
148
+ occupied area does not determine allocation. Save the whole region as JSON.
149
+
150
+ `splitRegion(region, leftContributionIds, { left?, right? }, budget?)` partitions
151
+ whole source footprints. Each side must remain connected. A singleton restores
152
+ its original identity/capacity; a side with multiple contributions requires an
153
+ explicit output id. Output ids may reuse their own side's contribution identity,
154
+ but cannot match a contribution from the opposite side. Invalid provenance, unknown/duplicate ids, empty or disconnected
155
+ sides, conflicting ids, mismatched kind/tier, overlap and exhausted budgets return
156
+ `null` (`canMerge` returns `false`). Optional `maxCells`/`maxContributions` budgets
157
+ limit total source cells/contributions before traversal; omitted budgets follow the
158
+ input size with linear work. These calls never mutate a grid: commit reservation
159
+ replacement separately. Width limits, upgrade pricing and arbitrary cell cuts stay
160
+ caller policy. Both split sides inherit the current kind/tier; when repricing capacity,
161
+ update the retained contributions as well as their total.
162
+
112
163
  ### World solids: collision for generated and studio geometry
113
164
 
114
- `ctx.world.solids` (`world/worldSolids` `WorldSolids`) is the static collision for world geometry that is not a scene object. It holds oriented boxes (`WorldSolid { center, halfExtents, rotationY? }`) in named layers. The player walk resolver and walking NPCs read it through `solidObstaclesNear`/`resolveWalkerStep`, `syncWorldColliders` mirrors it into a physics backend as static bodies, and `populateNavGridFromSolids` blocks it on a nav grid. Scene objects stay in `ctx.scene.object`; do not re-author generated buildings as placed objects to make them solid.
165
+ `ctx.world.solids` (`world/worldSolids` `WorldSolids`) is the static collision for world geometry that is not a scene object. It holds oriented boxes (`WorldSolid { center, halfExtents, rotationY? }`) in named layers. The player walk resolver and walking NPCs read it through `solidObstaclesNear`/`resolveWalkerStep`. Scene `moveToward` and `moveTowardCommit` gather the same indexed solids unless `avoidSolids: false`; bare `SolidObstacleSource` adapters inject them through `solids`. `syncWorldColliders` mirrors the store into a physics backend as static bodies, and `populateNavGridFromSolids` blocks it on a nav grid. Scene objects stay in `ctx.scene.object`; do not re-author generated buildings as placed objects to make them solid.
115
166
 
116
167
  - `environment({ structures: building(...) })` fills the `environment:structures` layer at context creation. `building({ solid: false })` keeps a set render-only.
117
168
  - Studio kinds declare collision with a `solids(resolved, object, params, context)` hook on `registerSceneKind`. `city` implements it for wall and roof pieces, with a `solid buildings` toggle in the inspector (`meta.solid`). `AuthoredScene` calls `syncAuthoredSolids(ctx.world.solids, document, sampleHeight)`, one `authored:<objectId>` layer per object; a headless host calls it itself.
118
169
  - For code-built geometry, derive solids from data and `set` a layer: `buildingSolids(buildings, groundHeight)`, `wallSolids(wallSegments(points, closed), { height, thickness })`.
119
170
  - A yawed solid reaches walkers as AABB strips along its long axis (`obstacleFromSolid`); physics backends get the exact oriented box.
171
+ - `createWorldSolids` supplies optional `inRay` candidates through crossed XZ hash cells, deduplicating entries and checking oversized solids. Traversals that cost more than a cached AABB scan or cannot advance representably use that scan without dropping blockers. Optional caller-owned `WorldSolidRayWork` counters report cell lookups, entry visits and bounds tests. `cellSize` must be finite and positive.
172
+ - Scene `raycast`/`raycastAll` prefer `inRay`, then intersect oriented boxes; `inBox`-only adapters retain the segment-box fallback. Origins, direction magnitudes and distances must be finite; negative distances throw `RangeError`, and zero directions return no hits. Bound each caller's `maxDistance`.
173
+ - Hits use existing `targetKind: "wall"`, `instanceId: "world-solid"`, physical/blocking/non-damage policy; `walls: false` or excluding `world-solid` omits them. LOS consumers pass `accept: (hit) => hit.blocks`; bare spatial/perception occlusion remains an injected policy. Projectile cover still follows `physics.projectileObstacles`.
174
+
175
+ ### Local collision routes
176
+
177
+ `planSolidRoute(from, to, options)` (`@jgengine/core/nav/solidRoute`) adapts movement's `CollisionObstacle` boxes into a bounded local grid search. Pass finite `bounds`, `cellSize` and `obstaclesInBounds`; query the scene/world index once with `solidObstaclesNear`, or use `sourceObstaclesNear` and a caller-owned reach cache in pure rules. The supplied query bounds already include radius clearance. Include every vertically relevant blocker; do not scan `list()`/`all()` per plan. Source query cost and cache invalidation remain source-owned; an index may fall back to scanning for a wide request.
178
+
179
+ The result is `path` with serializable 3D waypoints, `no-path` in the requested grid/bounds, or `budget` with no partial route. Endpoints are exact: blocked or out-of-bounds endpoints fail rather than snapping. Defaults cap allocation at 4096 cells, gathering at 256 boxes, search at 1024 expanded cells and collision/policy work at 100000 checks. Inspect `work`, then choose whether/when to widen, retry or stop. A clear direct route bypasses grid search, but the requested grid allocation cap is checked first.
180
+
181
+ Inject `sampleHeight`, body `radius`/`height`, edge `canTraverse` and terrain `stepCost`. Every edge and smoothing shortcut checks the entire segment against inflated movement boxes. Heights are sampled at waypoints; collision uses a conservative vertical envelope between them, so the caller must reject unsampled terrain hazards or use finer cells. A priced route stays unsmoothed unless `smooth: true` explicitly opts into shortcuts. Local cell resolution may miss a narrow connection; this is neither a full-world navmesh bake nor a guarantee of global unreachability.
182
+
183
+ Keep perception and behavior separate. Save waypoints and follower progress in caller storage; compose `pathFollow` for kinematic progress or feed targets to collision-aware `moveTowardCommit`. Keep the actual movement result authoritative: an axis-separated resolver can shorten a step beside a corner even when the continuous route is clear. Revalidate moving obstacles during movement, and invalidate/replan routes on goal or geometry changes. `findPathResult` (`nav/navGrid`) exposes bounded grid search separately; legacy `findPath` retains snapped endpoints and `null` for failure.
184
+
185
+ `maxNodes` counts expanded cells, excluding the reached goal. A route that reaches its goal after exactly that many expansions succeeds; a same-cell grid request needs no expansion.
120
186
 
121
187
  ### Physics world (optional, headless)
122
188
 
189
+ `ctx.sim.advance` owns authoritative `ctx.time` advancement. Its callback and registered stages receive real `dt` plus scaled `gameDt`; built-in player movement, AI, projectile travel, and the physics backend integrate `gameDt` and freeze when it is zero. Headless, hosted, and shell authority share this clock, including pause, speed, and timescale. Fixed drivers set movement tuning `authoritativeStep: true` to integrate their full scaled duration; standalone movement helpers retain their historical frame-stall clamp. A hosted client's explicit `{ advanceTime: false }` prediction keeps its real input-step duration without advancing authority; visual frame clocks never resolve hits or gameplay forces.
190
+
123
191
  `physics/physicsWorld` `PhysicsWorld` is a standalone fixed-capacity rigid-body sim (SoA buffers, spatial-hash broadphase, sleeping) — **not** the `defineGame` `physics: { gravity, jumpVelocity }` field, which the built-in walk controller reads directly every frame (see "Controller kinematics" above; both values are real and honored, not dead config). Reach for `PhysicsWorld` when a game needs many colliding dynamic bodies (piles, debris, stress scenes): `new PhysicsWorld({ capacity, bounds, … })`, `addBody({ position, mass?, ...shape })`, then `step(dt)` per tick → `PhysicsStats`. Core owns the sim; `@jgengine/shell/world/InstancedBodies` renders its bodies. Most games never need it — the character controller covers ordinary movement. The broadphase grid (`nx*ny*nz` cells from `bounds`/`cellSize`) throws at construction if it would exceed a sane cell cap — shrink `bounds` or raise `cellSize` (same guard on `physics/spatialGrid`'s `SpatialGrid`).
124
192
 
125
193
  **Body shape: box or sphere.** `AddBodyOptions` is a discriminated union — `{ shape?: "box", halfExtents }` (box is the default, `shape` omittable) or `{ shape: "sphere", radius }` (the radius fills all three half-extent columns, so broadphase/bounds see the sphere's enclosing AABB). `world.shape[i]` (`SHAPE_BOX` / `SHAPE_SPHERE`) reports a live body's shape for a consumer walking the raw SoA arrays. Sphere-sphere and sphere-box pairs resolve with a proper radial normal (not the axis-aligned box/box path) — balls, projectile bodies, and rolling debris collide correctly against both boxes and each other.
126
194
 
127
195
  **Collision shapes are box/sphere/voxel — plus opt-in triangle meshes for raycasts and mesh-accurate walk obstruction.** Every collider in the engine — `PhysicsWorld` bodies (box `halfExtents` or sphere `radius`), object/entity picking (`scene/objectQuery` raycasts against unit boxes), and `world/voxelField`/`world/carve` blocks — is an axis-aligned box, a sphere, or a voxel cell. The one exception is opted-in catalog assets: an asset listed in `@jgengine/assets` `COLLISION_MESH_ASSET_IDS` ships a compact quantized triangle mesh in the generated index (`scene/collisionMesh` `CollisionMeshData`, extracted from the GLB at reindex — never from the rendered scene, so a headless host resolves the same shape) **plus a deterministic compound-box decomposition** (`voxelizeToBoxes` → `CollisionMeshData.boxes`, a voxel-rasterize + column-fill + greedy-merge of the model's solid volume). Collider auto-fit then (a) raycasts the actual triangles through a precomputed BVH — shots pass through a torus hole or an archway instead of hitting the fitted box; and (b) exposes the compound boxes on the fitted `mesh` `ColliderShape` (`ColliderShape.mesh.boxes`, scaled/translated onto the placement like the mesh). The `collideObjects` walk controller consumes these in `stepPlayerMovement` (host and client alike, so parity is automatic): it gathers obstacles from resolved blocking physical colliders via a bounded `object.inBox` broadphase (reach ≥ radius + max half-extent, so a wide wall's full span blocks, not a phantom 1 m box), carries each collider's real `halfExtents`/`offset` — or, for a mesh collider, the compound sub-boxes — yaw-rotated onto the obstacle, and slides the capsule against **each** sub-box (`resolveObstacleStep`), so a capsule walks THROUGH an archway opening and is stopped by its pillars/lintel/sill. `PhysicsWorld` and overlap tests still see the conservative fitted AABB; objects whose colliders don't block stop obstructing, and non-opted assets keep the fitted box everywhere. Objects with no index `dims` are not stuck with the default unit box either: the shell measures what `renderObject`/dims-less models actually mount and reports it through `ctx.scene.object.reportBounds`, so a custom-rendered prop obstructs (and raycasts) as its real rendered footprint; only objects with nothing measurable keep the default box. The seams for custom *walk* collision remain `movement.beforeCommit` and object raycasts.
128
196
 
197
+ Heightfield player walking defaults to a 1.8 m physical height, overridden by `movement.collisionHeight`, and keeps its 0.3 m radius. At the accepted XZ endpoint it sweeps upward to ceiling undersides and downward to object tops; blocked curb/terrain rises try clear axes while preserving the configured grade policy. Landing intent follows accepted support. This remains axis-separated box collision, not a simultaneous diagonal body sweep or a visual rig-height fit. Default legacy starting overlaps, explicit `motion.setY`, and `movement.beforeCommit` corrections retain their existing authority. A fresh explicitly configured height that cannot fit above valid walkable support rejects with repair guidance; adjust the height or authored placement. Legal floor and curb contacts retain shared support behavior.
198
+
129
199
  **Ballistic collision sweep (`physics/ballisticSweep`).** `createBallisticSweep(world, { step?, radius? })` → `BallisticSweep`, a `(origin, velocity, gravity, maxTime) => BallisticSweepHit | null` function that marches the closed-form arc (constant gravity, straight lateral) through a `PhysicsWorld` and reports the first sample inside any live body's AABB (sleeping bodies included), refined by one bisection step; `null` means the whole arc is clear. `step` (default 1/60) is the march interval in seconds, `radius` (default 0) inflates every body's AABB before the point test — pass the projectile's own radius. Wire it into `combat/projectiles` via `ProjectileSystemDeps.sweepBallistic`: when set, a ballistic shot settles at the swept impact point instead of the closed-form landing; omitted or `null` falls back to that closed-form arc.
130
200
 
131
201
  **Removing and moving bodies.** `removeBody(id)` tombstones a body — it drops out of integration/broadphase and its slot is queued for the next `addBody` — without moving or invalidating any other body's `id` (ids are raw SoA slots, stored as-is in joints and game state, so nothing ever gets swapped). It conservatively wakes any sleeping body whose AABB touched the removed one's (no persistent contact set to consult, so this errs toward waking too much, never too little). `setVelocity(id, x, y, z)` and `setPosition(id, x, y, z)` write a body's velocity/position directly (instead of poking the public `velX`/`posX` SoA arrays) and wake it if asleep; `teleport(id, x, y, z)` is `setPosition` plus a hard velocity reset (respawn/teleporter, vs. sliding). `isAlive(id)`/`highWater` (one past the highest slot ever handed out) let a consumer that iterates the raw SoA arrays skip tombstoned holes correctly instead of assuming `count` is a dense `0..count` range.
@@ -136,6 +206,8 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
136
206
 
137
207
  **Actors on top of the sim:** `physics/ragdoll` (`createRagdoll(world, { bones, links, balance? })` — jointed bones, floppy or active-ragdoll via a balance motor), `physics/carryable` (`Carryable` — grab a body to a follow point, shared multi-owner carry, `carrySpeedMultiplier` encumbrance, drop/throw; the raycast pick is the caller's job, core owns the constraint), `physics/forceVolume` (`ForceVolume` — impulse/velocity/accelerate trigger region, `once` for boost pads; `PlatformCarry` — carry bodies standing on a moving platform by its per-`step` delta). Separately, `physics/spatialGrid` `SpatialGrid` is a broad-phase grid over the x/z plane, **distinct** from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers — `rebuild(count, xs, zs)` then `queryCircle` (swarm enemies hitting a player/AoE) or `forEachPair` (mutual separation).
138
208
 
209
+ `forEachPair(maxDistance, cb)` accepts finite, nonnegative distances independently of `cellSize`; it searches the clamped grid range that distance covers and reports every matching unordered pair once. Negative or nonfinite distances throw `RangeError`. Keep the distance local for bounded neighborhood work; a distance covering the whole grid can enumerate every pair.
210
+
139
211
  **Swarm-scale presentation.** `@jgengine/shell/world/SpriteBatch` renders up to `capacity` sprite-sheet quads as one `InstancedMesh` (per-instance atlas `frame`, `plane: "xy" | "xz"` or `billboard`, `pixelated` nearest-filtering) — the batched path for bullet-heaven swarms, item streams, and tile-grid presentation instead of one `<sprite>` per entity. Pair it with `world/lod`'s `createLodScheduler` (see the concept table) to band render detail by distance and throttle far entities' update work.
140
212
 
141
213
  **Traversal (`physics/traversal`).** `Grapple` fires a rope from a body to a fixed world point on the joint API — `fire(x,y,z)` attaches a `distance` (rigid) or `elastic` (spring) joint, `reel(dt)`/`payOut(dt)` shorten/lengthen the rope to pull the traveller in, `moveAnchor` re-points it (ziplines, grapple-to-moving-target). Grapple/zipline/swing (Sekiro, Deep Rock, Just Cause) are all the same primitive; the raycast that finds the anchor is the caller's. `Glide` is a reduced-gravity, forward-thrust wingsuit/glider over a body — call `apply(dt, steerX, steerZ)` each frame before `step` to feed back most of gravity (`gravityScale`), thrust along the steer vector, and clamp descent; stop calling it to fall normally, no attach/detach state.
@@ -143,10 +215,17 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
143
215
  **Group movement (`movement/formation` + `movement/avoidance`).** Three separable seams keep "where the group stands" apart from "how each member gets there" so a game can pair them with any route source. `placeFormation(destination, facing, count, generator)` maps a `FormationSlotGenerator`'s local `[right, forward]` offsets into world XZ slots around a target — formation shape is **caller data**, not an engine enum; `lineFormation`/`boxFormation`/`wedgeFormation`/`circleFormation` are sample generators and a custom one needs no engine edit. `assignFormationSlots(members, slots, { previous?, stickiness? })` is deterministic greedy nearest matching (`assignment[member] = slot`, `-1` when short on slots); `stickiness` biases a member toward its previous slot to cut per-frame reshuffle. `facingYaw(from, to)` orients a formation along its travel heading. Then `resolveLocalAvoidance(agents, { iterations?, strength?, padding?, weights? })` pushes overlapping circular agents apart in place — a bounded, deterministic Jacobi relaxation over an internal 3×3 hash grid (never an O(n²) whole-world scan), honoring per-agent radii and inverse-mass `weights` (`0` = pinned anchor/obstacle). Route planning (nav path, flow field, direct seek) stays the caller's; this owns slot geometry, matching, and separation only. Squads, parties, convoys, and non-formation crowds all compose the same three calls.
144
216
  ### Vehicles, mounts, crash damage & racing
145
217
  Five primitives layer a driving/racing game over the physics sim and `world/water`. All are **data-first** (spec the chassis/wheels/grip curve, damage thresholds, and checkpoint layout as catalog data) and pure `@jgengine/core`; renderers live in the game/shell. Each `update(dt, …)` runs **before** the shared `world.step(dt)`.
146
- - **Input buffer — `input/inputBuffer`.** `createInputBuffer({ windowMs })` remembers presses with caller-supplied ms: `press`/`release`, `consume(action, nowMs, windowMs?)` (spends a recent press once), `coyote(groundedAtMs, nowMs, windowMs)`, `hold`, `doubleTap`, `retune`, `snapshot`/`restore`. The walk controller keeps one per player for `movement.feel.jumpBufferMs` and `coyoteMs` (kinematic walk only; the capsule and voxel controllers jump on the press frame), and `snapshotPlayerMovement` carries it.
218
+ - **Input buffer — `input/inputBuffer`.** `createInputBuffer({ windowMs })` remembers presses with caller-supplied ms: `press`/`release`, `consume(action, nowMs, windowMs?)` (spends a recent press once), `coyote(groundedAtMs, nowMs, windowMs)`, `hold`, `doubleTap`, `retune`, `snapshot`/`restore`. The heightfield and physics-backed capsule paths share jump buffering, coyote grace, jump release, apex/fall gravity and landing recovery through `movement.feel`; the capsule owns physical landing and ceiling collision. Voxel jumping still uses the press frame. `snapshotPlayerMovement` carries each player’s buffer.
219
+ - **Authored jump response.** Supply the game's chosen `jumpBufferMs`, `coyoteMs`, `jumpCutFactor`, `apexGravityScale`, `apexSpeed`, `fallGravityScale`, `landingRecoveryMs` and `landingSpeedScale` defaults to `createMovementSchema` to expose those existing controls in the collapsed **Jump response** group. Omitted controls stay omitted. Ordinary catalog edits flow through `readAuthoredMovement` and `bindAuthoredMovement` into live `movement.feel`, and survive undo and save/reload without replacing game policy. Durations use milliseconds; `apexSpeed` uses meters per second. Values are finite and nonnegative; `jumpCutFactor` is bounded to `0..1`. `landingSpeedScale` keeps the runtime's support for values above 1. Invalid saved numbers report a located repair and inherit the game setting.
147
220
  - **Analog input — `input/axisInput`.** `AxisInput { throttle, brake, steer, handbrake }` is a continuous channel, **distinct from the digital action bindings**. `new AxisChannel({ bindings, smoothing })` ramps held keys into pedal-like analog values (`sample(dt, isDown)`), or `setAnalog(axis, value)` drives it straight from a gamepad axis. `DRIVE_AXIS_BINDINGS` is a ready WASD/arrow map. `sampleAxisBindings` is the instantaneous, unsmoothed read the channel ramps toward each frame — exposed headlessly as `ctx.input.axis(bindings, ranges?)`, bound to **action names** rather than raw key codes (#533.7).
148
221
  - **`physics/vehicleBody`.** `createVehicleBody(world, config)` is an arcade car: a chassis box body with per-wheel suspension held by G3's `springJoint` against the sampled `groundHeight`, drive/brake along the heading, and a `GripCurve` (`sampleGripCurve`) that bleeds lateral velocity for cornering — and, under `handbrake`, drift. `update(dt, axisInput)` then `world.step`. Because the chassis is a real body it still collides, which feeds crash damage. Rocket League, Trackmania, Wreckfest.
149
- - **`movement/playerMovement` rollback.** `snapshotPlayerMovement(ctx, userId)` copies one player's walk state (heading, facing, velocities, jump latch, capsule controller) as plain JSON; `restorePlayerMovement(ctx, userId, snapshot)` puts it back so the next `stepPlayerMovement` replays from there. Restore the entity pose from the entity store alongside it. This is what lets walking characters predict and roll back like `vehicleDynamics`.
222
+ - **Live movement tuning.** `resolvePlayerMovementTuning` keeps getters for movement feel and gravity/jump settings; an options getter can read the latest validated editor document without replacing the resolved tuning or resetting velocity. World terrain is resolved at creation. Semantic `crouch` requires the entity catalog’s supported crouch pose, overrides sprint, and suppresses jumping. `canFly` reads the accepted same-frame semantic stance once on the real context. A failed height preflight or thrown gate restores that pawn’s owned pose exactly before heading, queued motion or body state commits; unrelated caller effects remain caller-owned. The first motor state remains visible inside the gate, then is discarded if preflight fails; an existing motor state stays intact. Capsule standing waits for headroom; `CharacterController.setCrouch` resolves stance before a same-frame jump. The controller’s `skinWidth` intentionally leaves a small collision gap, not a visual foot-placement guarantee.
223
+ - **Heightfield climb policy.** `movement.maxClimbGrade` is an optional finite, nonnegative uphill rise/run limit. The shared walker accepts the full step or slides along X then Z, and samples foot grounding at the accepted position; the same path restriction applies during a jump without changing its vertical arc. Omit it to preserve unrestricted terrain travel. `climbGradeHeight(x,z)` can retain a game's raw terrain grade policy while feet follow the resolved ground (for example road cuts); it is called at most four times per step. This endpoint sampling does not detect every narrow ridge. Capsule, voxel and flight keep their own collision rules. The pure `resolveTerrainGradeStep` helper is exported from `@jgengine/core/movement/terrainGrade`.
224
+ - **Heightfield character proportions.** `movement.collisionHeight` supplies the positive, finite feet-to-head collision span; omit it to retain 1.8 meters. Declare the game's physical body explicitly rather than deriving it from a renderer or camera. `ModelConfig.targetHeight` sets the imported bind envelope and `scale` multiplies it; camera target/eye heights remain separate composition. Supply `collisionHeight` in the game's `createMovementSchema` defaults to expose the ordinary catalog control, then consume it with `bindAuthoredMovement`. Invalid saved values report a located repair and retain the game fallback. Live growth waits for headroom; shrinking takes effect immediately. Capsule and voxel paths keep their own dimensions, and this standing span does not add heightfield crouch clearance.
225
+ - **Rapier character surfaces.** Native casts exclude the character before finding the closest blocker. Collider surface normals distinguish ramps from vertical ledges; autostep is grounded only. A 20° ramp remains walkable with `stepHeight: 0`. Neither this nor controller tests prove rendered foot contact, animation appearance or hardware performance.
226
+ - **Movement state for presentation.** `playerMovementTelemetry(ctx, entityId)` reads the last completed shared motor step through indexed possession ownership. Its reused read-only view contains `grounded`, `verticalVelocity` and `crouching`; heightfield steps also expose optional `collisionHeight` (accepted span), `requestedCollisionHeight` and `collisionHeightBlocked` for inspecting a live edit waiting for headroom. These height fields are absent on capsule and voxel paths. `null` means unmanaged, not stepped, restored/forgotten, despawned, or a commit policy/pose constraint/absolute height override changed the motor proposal. This supports authored jump/land graphs without per-frame blackboard writes. Flight reports no ground contact or walking crouch; a custom mover supplies its own graph parameters.
227
+ - **Capsule proportions.** Standing and explicit crouch heights must fit a capsule diameter (`2 * radius`); omitted crouch height uses the larger of that diameter and 60% of standing height. `retune` recomputes omitted defaults while preserving explicit proportions; the shared player step retunes changed declared capsule fields before resolving stance. Invalid dimensions, nonfinite controls, negative gaps/steps and invalid slope/iteration limits throw before replacing valid config.
228
+ - **`movement/playerMovement` rollback.** `snapshotPlayerMovement(ctx, userId)` copies one player's walk state (heading, facing, velocities, jump latch, capsule controller) as plain JSON; `restorePlayerMovement(ctx, userId, snapshot)` puts it back so the next `stepPlayerMovement` replays from there. Restore the entity pose from the entity store alongside it. A driven actor's despawn clears its retained motor state, including same-ID respawn and possessed pawns. Pending restores survive until first spawn; ordinary hydration preserves continuity. A raw position update retains motor state; call `forgetPlayerMovement` when the game's relocation policy requires a fresh motor.
150
229
  - **`movement/movementProbe`.** `measureMovement({ movement, physics, walkSpeed }, { sprint? })` drives the walk integrator `stepPlayerMovement` uses through a standing start, a release, a reversal, a standing jump and a strafed jump, and returns deterministic feel numbers: `topSpeed`, `timeToTopSpeed`, `stopDistance`/`stopTime`, `turnAroundTime`, `jumpHeight`, `tapJumpHeight` (jump released after `tapSeconds`), `apexTime`, `airTime`, `airControlReach`. The dev runner's `walk-floaty` and `walk-weighty` are two characters built only from these fields. Pass the same `movement.feel` and `physics` fields the game gives `defineGame`, and pin the feel in a test.
151
230
  - **`physics/kinematicVehicle`.** `createKinematicVehicle(tuning, options?)` is the no-`PhysicsWorld` sibling of `vehicleBody` — same steer-yaw/accel/grip-curve arcade feel, driven purely by `tick(dt, axis, modifiers?)` returning `{ position, heading, forwardSpeed, lateralSpeed, slip, surface }`. `surfaceFriction`/`dragAt` hooks (grip-by-position, drag zones) and `clampMove` (walls/arena bounds) keep game flavor out of the sim; `scaleVelocity`/`resetTo` cover boost pads and respawns, and `snapshot`/`restore` cover rollback. Use it for karts, top-down cars and bumper cars, where heading following steer is the point. Its `chassis` and `steering` blocks are deprecated: when handling feel matters, use `physics/vehicleDynamics`.
152
231
  - **`physics/vehicleDynamics`.** `createVehicleDynamics(tuning, options?)` is the force-based ground vehicle: a two-axle model with per-axle Pacejka-style tires, load transfer, a friction ellipse, a gearbox or direct drive, aero, and ABS/TC/stability/caster assists. An optional `suspension` block with a `groundHeight` option adds four sprung corners (heave, pitch, roll, anti-roll bars, slope gravity, airborne and landing) for ramps, hills and jumps. Yaw comes from tire forces, so understeer, oversteer, lift-off tuck, power slides and handbrake turns follow from physical knobs in SI units. `tick(dt, axis, modifiers?)` returns pose plus telemetry (sideslip, slip angles, per-axle saturation, loads, rpm, `engineLoad`) for camera, sound and rumble; `snapshot`/`restore`/`retune` for prediction, replay and upgrades. Prefer it over `kinematicVehicle` whenever handling feel matters; tune it with [recipes/vehicle-feel.md](recipes/vehicle-feel.md).
@@ -158,7 +237,8 @@ Five primitives layer a driving/racing game over the physics sim and `world/wate
158
237
  - **`physics/buoyancy`.** `createBuoyantBody(world, { body, water, … })` floats a body on a CPU `WaterSurface` (Archimedes per hull point + water drag) so it settles at the waterline and rides the Gerstner waves; pass an `AxisInput` to `update(dt, time, input?)` and it drives as a boat (thrust + yaw + keel). Sea of Thieves, BOTW rafts.
159
238
  - **`scene/mount`.** `createMountController()` transfers control to a driven entity: `register({ id, kit, seats })`, `mount(riderId, mountId, seatId?)`, `dismount`. Read `cameraTarget(riderId)` to point the follow camera and `driveTarget(riderId)` to route that rider's `AxisInput` at the mount — the control seat drives, passenger seats ride (multi-seat shared vehicles), and an un-mounted rider drives themselves. `driver(mountId)`/`occupants(mountId)`/`kitOf`. Palworld mounts, V Rising horse, a crewed ship.
160
239
  - **`scene/stationClaim`.** `createStationClaim(controller?)` layers **facet stations** on `scene/mount` for a vehicle several players crew at once: `register({ id, kit, stations })` where each `Station` tags a seat with a `facet` (`"steer"`/`"sails"`/`"cannon"`). `claim(playerId, vehicleId, facetOrStationId)`, `release`, `controllerOf(vehicleId, facet)` (who mans it), `facetOf(playerId)`, `openFacets`, `crew`. Only a `control` station operates the hull (`driver`/`driveTarget`); the rest ride but command their own facet. Sea of Thieves helm + sails + cannons.
161
- - **`scene/vehicleSeat`** (#533.2). `createVehicleSeats(controller?)` composes `scene/mount`'s control-transfer with the enter/exit-vehicle transition every game hand-rolled: `register({ id, kit, seats? })` then `enter(riderId, vehicleId, options?)` resolves a free seat and returns `{ seat, cameraTarget, driveTarget, riderMovementPatch }` in one call — feed `cameraTarget` into `ctx.camera.follow(...)`, `riderMovementPatch` (`{ frozen: true }`) into `entities.update(riderId, { movement: { ...current, frozen } })`. `exit(riderId, vehiclePose, offset?)` computes a side-door placement (`distance`/`side: "left" | "right"`) next to the vehicle and returns the same triad in reverse (`riderMovementPatch: { frozen: false }`, `cameraTarget` back to the rider). Pure — no entity/camera side effects, the caller applies the patches via its own `ctx`. `isSeated`/`driverOf`; `.mounts` exposes the underlying `MountController` for direct dismount (e.g. a vehicle explosion that skips the placement math). GTA-style boarding, mountable creatures, boat docking.
240
+ - **`scene/vehicleSeat`.** `boardVehicle(ctx, seats, riderId, vehicleId, { seatId?, rig?, config?, chase?, camera? })` applies a successful `createVehicleSeats()` seat transition: freezes/hides that rider, clears velocity and retained walking motion, follows the vehicle, and applies caller camera policy. `leaveVehicle(ctx, seats, riderId, { side?, distance?, pose?, camera? })` places the rider beside the current vehicle (or an explicit last known pose), unfreezes/shows them, clears velocity, follows the rider and clears rig/chase overrides. Failed transitions leave state unchanged. Use `camera: false` for authoritative remote riders so commands cannot change local presentation. `drivenBy(riderId)` returns the driven vehicle or `null` for passengers/unseated riders; the underlying `mounts.driveTarget` still returns an unseated rider's own id. The pure `enter`/`exit` patch APIs remain available. Seats and `MountController` expose occupancy-only `snapshot`/`restore`/`reset`; register rideables before restoring, keep one instance per world, and explicitly register a stable caller save/replication key. A reset releases occupancy; a composed system leaves its riders first to restore entity patches. Motors remain separate caller-owned sims with their own snapshots.
241
+ - **Runtime camera rig.** `ctx.camera.setRig(kind, config?)` selects an existing rig with a plain-data config overlay; `null` restores the configured rig/config. Authored callbacks, Canvas projection/frustum and player FOV preferences stay configured by the game. Runtime chase tuning is independent: clear it explicitly or use `leaveVehicle`. The shell resolves the same override for rig mounting and input/reticle policy. Camera `snapshot`/`restore`/`reset` retain local follow/rig/cinematic/chase policy; `ctx.state()` saves it without broadcasting it in `ctx.snapshot()`. Only the documented chase yaw/FOV instant responses accept `Infinity`, encoded by an explicit marker for JSON saves. Transient FOV kicks are excluded, and older saves with no camera key leave current policy unchanged.
162
242
  - **`physics/damageZones`.** `createDamageModel({ zones, disableAt })` maps accumulated contact impulse (from `onCollision`) to **coarse discrete stages** (not soft-body): `absorb(zoneId, impulse)` / `routeCollision(event, resolveZone)` bump a zone's stage (caller swaps the visual/collider), an optional `detachStage` ejects a part as debris once, and crossing `disableAt` flips a whole-vehicle `disabled` state. Wreckfest crumple/derby.
163
243
  - **`game/race`.** `raceTrack({ checkpoints, laps })` is an ordered ring of AABB checkpoint volumes (the final one is the finish line); `createRaceState({ track, win })` — driven each tick by `update(now, positions)` on game time — emits `checkpoint.hit` / `lap.completed` / `position.changed` / `race.finished`, keeps split times, resolves a pluggable `RaceWinCondition` (`firstPastPost`, `topK` round-cut, `everyoneFinishes`, `lastStanding` derby), and `resetToCheckpoint(id)` hands back a respawn pose. `removeRacer(id)` drops a racer mid-race and renumbers the remaining standings; `reset()` clears all racer progress/finish state back to construction time so the same instance replays without rebuilding it. Trackmania, Mario Kart, Fall Guys.
164
244
  - **Session lifecycle** (`RaceSessionState`, phases `idle → countdown → racing → finished`) — the pure clock every racer wraps around `RaceState`, so no game hand-rolls its own `RacePhase`/`COUNTDOWN_SECONDS`. `idleRaceSession()` is the grid; `startRaceCountdown({ seconds })` drops the lights (default 3, `seconds ≤ 0` = standing start); `tickRaceSession(session, dt)` bleeds the countdown then accumulates `elapsed` once green; `finishRaceSession(session)` freezes the clock at the flag. It carries no track/lap data — compose it with `RaceState` (positions/laps) and `createLapTimer` (per-lap wall-clock). A racing game layers medals/DNF on top of it.
@@ -268,6 +348,33 @@ bc.status(id) / bc.reason(id) / bc.inspect(id) / bc.list(); // editor/debug insp
268
348
  ```
269
349
 
270
350
  Path progress adapter (`nav/pathFollow`): `PathProgress` is `{ kind: "normalized", value } | { kind: "distance", value } | { kind: "segment", index, fraction }` — travel direction falls out of the resulting `heading`. `pathFollowSeek(config, progress)` places a follower without simulating from the start (loops wrap, non-loops clamp + report `done`); `pathFollowProgress(config, state)` reads it back as `{ distance, normalized, segment, fraction }`; `pathLength(config)` sums the polyline. Vice-isle's traffic + pedestrians run entirely on this seam (phase-distributed `startProgress`, `pause` on drive-away, `disable` on wanted-panic) — no hand-rolled route loop.
351
+
352
+ `pursue({ aggroRadius, reach, leashRange, speed, attack: { effect, amount, intervalSec }, thinkInterval?, cooldownMode?, threat? })` attaches scheduled target acquisition, direct solid-aware chase, cooldown effects and return to the original `spawnPoseOf` home. By default the descriptor declares player-role entities hostile; acquisition queries only its radius. A live explicit `ctx.scene.entity.setTarget(id, targetId)` wins, then an optional threat-table taunt, then threat score and nearest eligible player. The held target may leave the aggro radius; the home leash ends the chase and latches return until arrival. `behaviorControl(ctx).threat(id)` exposes the existing threat API only when `threat` was configured. `reset(id)` clears target, cooldown, threat and cadence, retaining home and pose.
353
+
354
+ For caller-defined allies and neutral players, configure `defineGame({ pursuit: {
355
+ eligible(ctx, selfId, candidateId) { return rostersByWorld.get(ctx)?.isHostile(selfId, candidateId) === true; }
356
+ } })`, where `rostersByWorld` is a caller-owned `WeakMap<GameContext, FactionRoster>`
357
+ using `createFactionGraph`/`createFactionRoster` from `@jgengine/core/faction/factions`.
358
+ The callback receives the live world and entity IDs at scheduled pursuit ticks.
359
+ It is additional authorization for nearest, retained, explicit and taunt targets;
360
+ `setTarget` and threat never bypass it or the existing self/existence/`canReceive`
361
+ checks. A supplied callback accepts only by returning exactly `true`; missing,
362
+ null, object or Promise results deny rather than enabling the default. Automatic acquisition remains player-role only; an explicitly selected
363
+ nonplayer can be accepted by the policy. Keep each lookup bounded.
364
+
365
+ Policy functions are injected, not behavior-save data. Bind each receiving world's
366
+ roster before its first behavior advance after loading; save the caller's faction
367
+ facts through its own store/module when required. A loaded target is rechecked
368
+ against that world's current policy without resetting its home, cooldown or cadence.
369
+ Without a callback the existing all-player-role default is unchanged.
370
+
371
+
372
+ `thinkInterval` defaults to 0.1 seconds; zero ticks each frame. Stable IDs stagger sibling ticks; skipped frame time is accrued for movement/cooldown, with at most one effect per think. `cooldownMode` defaults to `"always"`; `"inRange"` freezes it outside reach. Pause and disable freeze pursuit; resume uses freeze even when passed `"advance"`. Effects use `ctx.scene.entity.effect`, including receive/death/onDeath policies. Direct chase slides against shared solids; it does not plan detours or infer line of sight. Use the navigation/decision seams for those policies.
373
+
374
+ GameContext registers a lazy `pursuitBehaviors` snapshot module before boot-time restore. It saves home, target, explicit target, cooldown, gate, pending time, optional threat and lifecycle status; viewer projection uses the entities' same AOI set. Each supplied record is validated before any pursuit record is changed. Hydration changes supplied pursuit instances, leaving other behavior kinds untouched; an absent legacy module leaves pursuit state untouched. Empty worlds carry `{ version: 1, instances: [] }`. Instance `serialize`/`restore` clones state and rejects invalid pursuit snapshots.
375
+
376
+ Replace behavior data through `ctx.scene.entity.update(id, { behaviors })` or entity hydration. The runtime reconciles that ID before ordinary subscribers; changed active descriptors create a fresh instance, while equal-data hydration and unrelated prompt changes retain its progress, blackboard and lifecycle. Removing the active descriptor stops its runtime. `scene.entity.subscribeBehaviors(listener)` (also on `EntityStore`) reports changed descriptor data/removal by ID and returns an unsubscribe; pose writes and equal-data replacement do not notify. Compare work happens only at descriptor write boundaries, and updates do not scan the entity population. A replaced/removed running decision graph calls its existing `onAbort` once with the compiled action's exact params. `DecisionGraphRuntime.abort(ctx, blackboard)` provides that cleanup directly; it clears running ownership before user code and propagates callback errors while retaining graph memory/timers.
377
+
271
378
  ### Entity orders (command queue)
272
379
 
273
380
  `orders/orderQueue` + `orders/orderKinds` — a serializable per-entity command queue for player- or AI-issued intent, instead of a hard-coded RTS verb kit. Build a shared registry once with `createOrderRegistry<Ctx>()` and register verbs as data; engine code never branches on a verb. Give each commandable unit its own `createOrderQueue(registry, { genId?, onEvent? })`.
@@ -295,6 +402,10 @@ Compose, don't replace: scale `MobBrainConfig.aggroRadius` by `perceptionScale`,
295
402
 
296
403
  `movement/glideModel` `createGlideModel`, `world/lod` `createLodScheduler`, `visibility/simulationCulling` `createSimulationCuller` and `visibility/spatialIndex` `createSpatialIndex` hand back a plain JSON `snapshot()` and take it back with `restore(next)`; `movement/grappleSwing` `createGrappleSwing` pairs its existing `state()` with `restore(state)`. A restored spatial index keeps cell and id order, so queries return ids in the same order.
297
404
 
405
+ ### Asset loading lifecycle
406
+
407
+ Asset loading uses `visibility/assetStreaming`'s `createAssetStreamingSystem`: `maxLoadsPerFrame` limits starts per tick; `maxConcurrentLoads` caps unresolved requests (default 4), including cancelled requests until their loader settles. `retune` changes these policies live; zero concurrency pauses new loads. Same-id retries wait for cancelled work to settle. `clear` releases loaded resources and cancels demand while `settle` still waits for outstanding work; successful cancelled loads are released through `unload`. Supply an `unload` callback for resources that need disposal, and make loaders settle after cancellation. Set `maxResidentBytes` to opt into a per-tick memory target; pins/references remain protected, while byte pressure overrides grace and small-asset retention. Use `residentEvictionOrder` (`oldest`/`largest`) and `stats().overBudgetBytes` to tune it. Historical records are retained until explicit `forget(id)` succeeds for inactive, unowned, settled assets; see [visibility.md](visibility.md#resident-memory-and-history) for the lifetime contract.
408
+
298
409
  ### Snapshot and restore on AI, faction and sensor handles
299
410
 
300
411
  These handles hand back a plain JSON `snapshot()` that later ticks do not mutate, and take it back with `restore(next)`, so a host, save file or replay can rewind them bit-exactly: `ai/threat` `createThreatTable`, `ai/groupAssist` `createAssistNetwork` (membership only; `restore(next, tableOf)` rebinds each member's separately restored threat table), `faction/reputation` `createReputationLedger`, `sensor/concealment` `createConcealmentSensor`, `sensor/freezeMonitor` `createFreezeMonitor` and `sensor/recordingBuffer` `createRecordingBuffer` (frame `data` is shared, not cloned).
@@ -302,3 +413,54 @@ These handles hand back a plain JSON `snapshot()` that later ticks do not mutate
302
413
  ### Snapshot and restore on building handles
303
414
 
304
415
  `world/footprintGrid` `createFootprintGrid` (reservations in claim order; restore rebuilds occupancy), `world/walls` `createWallDrawTool` (points and closed flag) and `world/terraform` `createTerraformBrush` (radius and strength; the terrain keeps its own `snapshot()`) hand back a plain JSON `snapshot()` and take it back with `restore(next)`. `world/interiors` `createInteriors` holds config only, so it has nothing to save.
416
+
417
+ ## Entity proximity queries
418
+
419
+ `ctx.scene.entity.inRadius` and `queryArc` use an incremental grid. All entity store writes, including pose/update, spawn/despawn, reset and replication/save hydration, update the derived index before change subscribers run. The live entity ID list keeps its identity across value writes. Mutating a live entity directly bypasses notification: prefer the store verbs, or call `ctx.scene.entity.invalidateSpatial()` after such a batch. Spatial caches are derived from entities and never enter saved or replicated state.
420
+
421
+ For a bare `createSpatialApi` (`@jgengine/core/scene/spatial`), opt into `incremental: true` only when every candidate membership/position change calls `updateEntity` after the authoritative write, or invalidates the index. Subset consumers whose positions resolve outside their candidate set must pass `updateEntity(id, candidateSet.has(id))`: `false` removes without looking up the position, and `true` retains a candidate whose position is not known yet. Omitted presence asserts membership when the position resolves, otherwise removes; it suits the runtime's all-entity store. Cold/invalidated indexes and large membership bursts rebuild on the next query. Warm queries visit nearby cells plus unresolved IDs; each unresolved ID costs one lookup per query until updated or invalidated. Dense nearby cells still cost their local population. Cell crossings preserve the same candidate ordering as a full rebuild and reuse emptied cell buckets.
422
+
423
+ The default bare API retains discovery of newly added candidate IDs without notification, at the cost of enumerating all candidate IDs on each query. Existing `invalidate()`/`getVersion()` and `grid: false` behavior remains available. `createEntityStore({ onChange })` offers a committed-ID/membership hook before normal subscribers for an independently composed index; it does not replace existing general or membership subscriptions.
424
+
425
+ ## Spatial audio and playback budgets
426
+
427
+ Games own the sound catalog and music composition. `PlayableGame.audio.maxVoices` bounds simultaneous SFX playbacks/patch reservations, including pending sample loads; each caller-defined synth patch shares one slot across its oscillators (default 64); `voiceOverflow` chooses `"steal-lowest"` or `"reject"`. `SoundDef.maxVoices` caps one sound and `priority` protects higher-priority cues. Equal priorities steal the oldest reservation. Music runs through its own director outside this SFX budget.
428
+
429
+ The shell feeds camera position, forward, up, and velocity into `setListenerPose`; a legacy bare position still uses forward -Z and up +Y. `SoundDef.spatial` enables a panner whose distance attenuation replaces scalar falloff while preserving authored gain. `positional: false` keeps UI cues flat, even when spatial settings are present. Loop handles copy positions and velocities and accept updates before samples finish loading. `isPlaying()` includes pending loads; an explicit `audio.loopStart` retries a stolen/stopped loop while active starts stay idempotent. Legacy handles without the optional probe keep their previous behavior.
430
+
431
+ For custom audio hosts, `createVoiceAllocator` (`@jgengine/core/audio/voiceAllocator`) exposes detached reservation `snapshot`/`restore`, injected `VoiceAllocatorStorage`, and `retune`. Pass a dedicated allocator to `createAudioEngine` (`@jgengine/shell/audio/audioEngine`) to inspect reservations; use `engine.setVoiceLimit(total, overflow?)` to retune an active engine so removed reservations also stop their graphs. Reservation snapshots describe admission state, not browser nodes or resumable audio playback. Stop every id returned by allocator stealing or retuning when using it outside the shell.
432
+
433
+ Cancelled or stolen pending cues never create a late source. Natural sample/synth completion, failed loads, explicit stops, and engine teardown release reservations and disconnect source, filters, per-cue gains, and panners. Mocked graph tests establish routing and lifecycle; they do not establish audible quality.
434
+
435
+
436
+ Custom authored primitive renderers use `StaticShapeInstances` (`@jgengine/shell/render/staticShapeInstances`) with document-derived box/cylinder transforms and caller-owned `AuthoredSurfaceConfig`. Boxes span one unit; cylinders use unit radius/height. Grouping includes maps, sampler settings, PBR/rim values, tessellation and shadow policy. Updates upload matrices and recompute conservative bounds; positive finite scale is required. Bounds include an affine-parent safety factor, so culling may retain extra geometry. Generator markers (including Field Station's authored bookcase) consume this seam automatically without changing generator data or material defaults.
437
+
438
+ `ModelMaterialMaps` derives its URL fields from `MaterialTextureRole`; ordinary models and authored surfaces accept all 20 supported roles, including alpha, sheen, anisotropy, clearcoat, specular, transmission, thickness and iridescence maps. `modelAssetRequests` discovers supplied maps through inline parts and attachments; unknown roles remain outside its request list. Legacy color/normal/roughness/ao/metalness/emissive/height order stays stable, followed by the remaining authority-defined roles. Authored surfaces keep their owned resources for equivalent config/map ordering and reload when a supported URL changes. Omitted model roles retain imported maps; games choose URLs and physical response parameters. This is the simple all-slot override seam; per-slot `MaterialAsset` assignments retain their own selectors and metadata.
439
+
440
+ `AuthoredSurfaceMaterial` / `useAuthoredSurfaceMaterial` (`@jgengine/shell/render/authoredSurfaceMaterial`) share model-material map roles and material application. They own material/texture clones; cached loader textures remain shared and untouched. Colour, emissive, sheen-colour and specular-colour maps are sRGB; normal and numeric roles are linear, following `MATERIAL_TEXTURE_SEMANTICS` from `@jgengine/core/material/materialAsset`. `wrapping` selects repeat (default), clamp or mirror; `anisotropy` defaults to 1. `normalScale` is explicit, `repeatMetres` optionally tiles unit shapes in physical metres including instance/parent scale (cylinder sides approximate an elliptical circumference). Omitting it retains authored UVs. Height maps must be normalized to [0,1]; `displacementScale`/`displacementBias` use local shape units and expand batch bounds. Compose local palette, asset URLs, opacity/depthWrite and emission in the game. No scene coordinates or surface families belong in these primitives; editor placement stays authoritative. Deepward's prefab-authoring fallback is tracked by #1937 and should be removed when editor composition can express it.
441
+
442
+ ### Baked authored prefab assets
443
+
444
+ A static prefab bake supplies measured `dims`, triangle `collisionMesh` with authored movement
445
+ `boxes`, and `anchor: "origin"`. Register these with its URL as a `ModelAssetRef` (or a
446
+ `buildCatalog` extra). The shared catalog model resolver forwards the origin and collision mesh
447
+ to both model rendering and runtime collider fitting, including unmapped objects resolved by their
448
+ own catalog id. Explicit object model mappings still win. This retains the prefab's local placement
449
+ frame. Place its catalog id through the existing authored-object document path. Baking is offline;
450
+ see `jgengine-editor` for persisted settings and `jgengine-assets` for verified source export.
451
+
452
+ ### Authored particle lifecycle and outputs
453
+
454
+ Authored scene simulation emitter descriptors feed the existing `ctx.particles` director. `attach(id, config, options)` installs a named effect; `start`/`stop` toggles spawning while live particles finish, `burstNamed` emits once into that pool, and `retune(id, patch, options)` changes config or binding. `limits()` exposes fixed registry and queue capacities; standing-emitter overflow throws before mutation. Snapshot/restore retains named definitions and active state; transient bursts and cosmetic events drain once.
455
+
456
+ `EmitterConfig.spawnShape` selects a disc, sphere, or cone volume, optionally its surface; `forces` samples the shared `physics/forceVolume` descriptors as cosmetic acceleration. `collision` bounds scene queries and retained contact events. `ParticleAttachOptions.space` chooses world trails or a local followed pose; explicit seeds survive the renderer. `render.shape` selects points, streaks, flakes, flames, smoke, connected ribbons, or ground ripples through the same fixed pool. `options.environment` explicitly opts into the live shared force/wind sampler with `windResponse` in 1/second (0..1e12), `maxAcceleration` in metres/second² (0..1e12), and `mask`; omission keeps that coupling off. Shell pools use authoritative game-time deltas, respecting pause and time scaling, with cosmetic suspension catch-up capped at 0.1 seconds. `collisionEffect` emits at most sixteen child bursts per frame with collisions disabled, preventing cascades. `drainEvents()` exposes bounded cosmetic contacts; authoritative forces, exposure, damage, and projectile settlement remain simulation-owned.
457
+
458
+ `ProjectileModels` (`@jgengine/shell/combat/ProjectileModels`) renders live authoritative flights with a caller `ModelConfig`, or explicit shaft/head geometry via `fallback`. `AuthoredFlocks` (`@jgengine/shell/world/AuthoredFlocks`) renders cosmetic habitat agents from caller-selected species models and existing looping GLB animation. Both follow actual velocity and remove models when the owning simulation removes them; gameplay habitat actors stay in the game's entity composition.
459
+
460
+ Development `debug_snapshot` reports real renderer draws/resources and `probes.particles` reports aggregate live particles, fixed capacity, buffer bytes, pooled update timing, and field mount/disposal counts. `resources.attributeBytes` counts renderer attribute arrays; it does not estimate driver VRAM. Delete authored emitters through the editor and wait rendered frames before checking that live pools/fields/attribute bytes return to zero.
461
+
462
+ `installEnvironmentMotion(ctx,{targets,maxTargets})` (`@jgengine/core/world/environmentForces`) queues capped wind/field velocity changes before shared movement. Games select actor IDs, motion sinks, `windResponse` in reciprocal seconds, acceleration caps in m/s² and force masks; `sheltered:true` scales the influence by authored exposure. Dispose the returned stage handle with the game loop. Cosmetic quality cannot change this stage. Habitat `windResponse`/`maxAcceleration`/`forceMask` opt existing flock steering into the same sampler, while species and gameplay actor rules remain game content.
463
+
464
+ Authored composition follows the separation of spawn, initialize/update, events and output in [Niagara](https://dev.epicgames.com/documentation/en-us/unreal-engine/overview-of-niagara-effects-for-unreal-engine) and [Unity VFX contexts](https://docs.unity3d.com/Packages/com.unity.visualeffectgraph@17.0/manual/Contexts.html). Signed attraction, attenuation, directionality and opt-in masks follow [Godot particle attractors](https://docs.godotengine.org/en/stable/classes/class_gpuparticlesattractor3d.html). These contracts compose existing owners; they do not provide arbitrary graphs or sampled vector-field resources.
465
+
466
+ `defineGame({ scenePathKinds: ["road"] })` forwards the existing authored-path kind filter to the automatic scene overlay; route-only flight or patrol guides remain simulation inputs without becoming ground roads. Omission preserves the existing all-path presentation.
@@ -20,6 +20,9 @@ the renderer.
20
20
  the cells that changed; static objects are inserted once.
21
21
  - **Bounds caching** — bounds recompute only when an object's `version` advances (transform,
22
22
  geometry, or override change), reusing the cached object so steady-state frames allocate nothing.
23
+ The shell tracks each live entity/object separately, invalidates placed-object bounds when visual
24
+ scale changes, and drops tracking entries after removal. `alwaysVisible` overrides stay attached
25
+ to their own rows even when several rows bypass culling in one frame.
23
26
 
24
27
  Every game improves immediately after upgrading, because defaults favor visual stability: a preload
25
28
  margin larger than the view, hysteresis so objects don't flicker at the boundary, and a large
@@ -36,7 +39,10 @@ default render distance.
36
39
  | streaming `preloadMargin` | 32 | Assets load ahead of visibility |
37
40
  | `unloadGraceSeconds` | 10 | Grace period before an idle asset is unloaded |
38
41
  | `maxLoadsPerFrame` / `maxUnloadsPerFrame` | 4 / 2 | Per-frame budgets to avoid spikes |
39
- | `keepResidentBytes` | 64 KiB | Small shared assets stay resident |
42
+ | `maxConcurrentLoads` | 4 | Unresolved loads, including cancelled work |
43
+ | `maxResidentBytes` | `Infinity` | Optional known resident-byte target |
44
+ | `residentEvictionOrder` | `oldest` | Budget-pressure eviction; `largest` releases largest assets first |
45
+ | `keepResidentBytes` | 64 KiB | Small shared assets survive idle eviction; byte pressure overrides this |
40
46
 
41
47
  ## How to opt out / override
42
48
 
@@ -71,6 +77,27 @@ An asset is never auto-unloaded when it is `pinned`, retained (shared by an acti
71
77
  required by the current scene. `AssetStreamingSystem.pin(id)` / `retain(id)` from game code; or set
72
78
  `neverUnload` / `pinned` on the object override so its assets stay resident.
73
79
 
80
+ ## Resident memory and history
81
+
82
+ `createAssetStreamingSystem({ settings: { maxResidentBytes: 256 * 1024 * 1024 }, load, unload })`
83
+ opts into byte-pressure eviction. Supply approximate CPU/GPU resident bytes from `load`, not file
84
+ sizes. `retune` changes the byte target and `residentEvictionOrder` during play. `oldest` evicts
85
+ least recently active assets; `largest` evicts by size, then activity; both break ties by asset id.
86
+
87
+ Each tick spends `maxUnloadsPerFrame` on pressure eviction before idle eviction. Pressure bypasses
88
+ `unloadGraceSeconds` and `keepResidentBytes`, but never pins or references. This is a convergence
89
+ target, not a hard allocation ceiling: loads complete between ticks, protected assets may exceed
90
+ the target, and missing/invalid sizes count as zero. `stats()` exposes `overBudgetBytes`,
91
+ `protectedBytes`, `unknownSizeLoaded`, and cumulative `budgetEvicted`. Retain currently used
92
+ resources; refreshing activity only affects eviction order under pressure.
93
+
94
+ Unloaded and failed records remain queryable by default. After releasing ownership and cancelling
95
+ demand, call `forget(id)` to discard inactive history. It refuses loaded, queued, unresolved,
96
+ pinned, or retained assets, including cancelled loads until settlement. Successful forgetting
97
+ makes `stateOf`/`record` return `undefined`; previously held records become detached, and future
98
+ requests create fresh records. Call it after eviction/cancellation settlement in a caller-owned
99
+ cleanup pass to bound history; `stats().records` measures it. Eviction scans resident records only.
100
+
74
101
  ## Multiple cameras
75
102
 
76
103
  The `VisibilitySystem` unions visibility across every active `CameraVisibilityContext` — split-screen,