@jgengine/core 0.18.1 → 0.19.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 (220) hide show
  1. package/CHANGELOG.md +158 -7
  2. package/dist/ai/decisionGraph.d.ts +48 -4
  3. package/dist/ai/decisionGraph.js +153 -23
  4. package/dist/ai/groupAssist.d.ts +10 -0
  5. package/dist/ai/groupAssist.js +11 -0
  6. package/dist/ai/pursuit.d.ts +1 -1
  7. package/dist/ai/pursuit.js +1 -1
  8. package/dist/ai/threat.d.ts +8 -0
  9. package/dist/ai/threat.js +10 -0
  10. package/dist/anim/animGraph.d.ts +11 -0
  11. package/dist/anim/animGraph.js +128 -0
  12. package/dist/anim/boneTexture.d.ts +55 -0
  13. package/dist/anim/boneTexture.js +43 -0
  14. package/dist/anim/footPlacement.d.ts +57 -0
  15. package/dist/anim/footPlacement.js +67 -0
  16. package/dist/anim/locomotionGraph.d.ts +9 -0
  17. package/dist/anim/locomotionGraph.js +29 -0
  18. package/dist/audio/audioFalloff.d.ts +2 -0
  19. package/dist/audio/doppler.d.ts +21 -0
  20. package/dist/audio/doppler.js +32 -0
  21. package/dist/audio/engineLayers.d.ts +83 -0
  22. package/dist/audio/engineLayers.js +115 -0
  23. package/dist/combat/abilityKit.d.ts +1 -0
  24. package/dist/combat/abilityKit.js +1 -0
  25. package/dist/combat/death.d.ts +7 -0
  26. package/dist/combat/shotOrigin.d.ts +6 -0
  27. package/dist/combat/shotOrigin.js +6 -0
  28. package/dist/combat/weaponFeedback.d.ts +36 -0
  29. package/dist/combat/weaponFeedback.js +42 -0
  30. package/dist/combat/weaponHandling.d.ts +161 -0
  31. package/dist/combat/weaponHandling.js +202 -0
  32. package/dist/combat/weaponPresentation.d.ts +115 -0
  33. package/dist/combat/weaponPresentation.js +96 -0
  34. package/dist/devtools/warnOnce.d.ts +8 -0
  35. package/dist/devtools/warnOnce.js +18 -0
  36. package/dist/editor/document.js +3 -1
  37. package/dist/editor/environment.js +8 -0
  38. package/dist/editor/types.d.ts +4 -0
  39. package/dist/faction/reputation.d.ts +6 -0
  40. package/dist/faction/reputation.js +11 -0
  41. package/dist/game/cameraConfig.d.ts +51 -1
  42. package/dist/game/events.d.ts +6 -0
  43. package/dist/game/lootTable.d.ts +4 -0
  44. package/dist/game/lootTable.js +16 -0
  45. package/dist/game/playableGame.d.ts +84 -17
  46. package/dist/game/toasts.d.ts +8 -0
  47. package/dist/game/toasts.js +7 -0
  48. package/dist/game/vfxInstance.d.ts +9 -0
  49. package/dist/game/vfxInstance.js +12 -1
  50. package/dist/game/viewports.d.ts +43 -0
  51. package/dist/game/viewports.js +46 -0
  52. package/dist/game/worldItem.d.ts +5 -1
  53. package/dist/game/worldItem.js +0 -1
  54. package/dist/input/actionBindings.d.ts +15 -1
  55. package/dist/input/actionBindings.js +14 -3
  56. package/dist/input/actionContexts.d.ts +25 -1
  57. package/dist/input/actionContexts.js +82 -25
  58. package/dist/input/axisInput.d.ts +4 -0
  59. package/dist/input/axisInput.js +4 -0
  60. package/dist/input/axisShaper.d.ts +73 -0
  61. package/dist/input/axisShaper.js +105 -0
  62. package/dist/input/gamepadModel.d.ts +36 -2
  63. package/dist/input/gamepadModel.js +69 -30
  64. package/dist/input/haptics.d.ts +50 -0
  65. package/dist/input/haptics.js +74 -0
  66. package/dist/input/inputBuffer.d.ts +40 -0
  67. package/dist/input/inputBuffer.js +59 -0
  68. package/dist/input/lookChannel.d.ts +10 -0
  69. package/dist/input/lookChannel.js +6 -0
  70. package/dist/input/pointer.d.ts +2 -0
  71. package/dist/input/pointer.js +4 -0
  72. package/dist/item/affix.d.ts +6 -0
  73. package/dist/item/affix.js +6 -0
  74. package/dist/meta/changelog.d.ts +1 -1
  75. package/dist/meta/changelog.js +112 -1
  76. package/dist/movement/glideModel.d.ts +3 -0
  77. package/dist/movement/glideModel.js +6 -0
  78. package/dist/movement/grappleSwing.d.ts +2 -0
  79. package/dist/movement/grappleSwing.js +5 -1
  80. package/dist/movement/movementModel.d.ts +39 -0
  81. package/dist/movement/movementModel.js +62 -19
  82. package/dist/movement/movementProbe.d.ts +51 -0
  83. package/dist/movement/movementProbe.js +119 -0
  84. package/dist/movement/playerMovement.d.ts +21 -2
  85. package/dist/movement/playerMovement.js +89 -6
  86. package/dist/movement/solidObstacles.d.ts +9 -1
  87. package/dist/movement/solidObstacles.js +44 -2
  88. package/dist/movement/voxelController.js +7 -7
  89. package/dist/nav/navFromEnvironment.d.ts +9 -3
  90. package/dist/nav/navFromEnvironment.js +18 -12
  91. package/dist/nav/navMesh.d.ts +43 -2
  92. package/dist/nav/navMesh.js +534 -128
  93. package/dist/physics/aircraftDynamics.d.ts +390 -0
  94. package/dist/physics/aircraftDynamics.js +704 -0
  95. package/dist/physics/boatDynamics.d.ts +116 -0
  96. package/dist/physics/boatDynamics.js +160 -0
  97. package/dist/physics/drivableVehicle.d.ts +22 -8
  98. package/dist/physics/drivableVehicle.js +4 -2
  99. package/dist/physics/flightDynamics.d.ts +21 -4
  100. package/dist/physics/flightDynamics.js +28 -5
  101. package/dist/physics/handlingProbe.d.ts +249 -0
  102. package/dist/physics/handlingProbe.js +606 -0
  103. package/dist/physics/kinematicVehicle.d.ts +32 -2
  104. package/dist/physics/kinematicVehicle.js +15 -0
  105. package/dist/physics/physicsBackend.d.ts +12 -0
  106. package/dist/physics/physicsBackendConformance.js +34 -0
  107. package/dist/physics/physicsWorldBackend.d.ts +1 -0
  108. package/dist/physics/physicsWorldBackend.js +33 -0
  109. package/dist/physics/vehicleBackendLink.d.ts +49 -0
  110. package/dist/physics/vehicleBackendLink.js +102 -0
  111. package/dist/physics/vehicleDynamics.d.ts +374 -0
  112. package/dist/physics/vehicleDynamics.js +737 -0
  113. package/dist/physics/worldColliders.d.ts +1 -1
  114. package/dist/physics/worldColliders.js +35 -3
  115. package/dist/puzzle/cellGrid.d.ts +15 -9
  116. package/dist/puzzle/cellGrid.js +13 -9
  117. package/dist/render/lookPreset.d.ts +9 -2
  118. package/dist/render/lookPreset.js +29 -2
  119. package/dist/render/postProcessing.d.ts +30 -0
  120. package/dist/render/postProcessing.js +1 -1
  121. package/dist/runtime/cameraDirector.d.ts +17 -3
  122. package/dist/runtime/cameraDirector.js +25 -0
  123. package/dist/runtime/commandInput.d.ts +37 -0
  124. package/dist/runtime/commandInput.js +58 -0
  125. package/dist/runtime/commandRunner.d.ts +27 -0
  126. package/dist/runtime/commandRunner.js +31 -3
  127. package/dist/runtime/context/combat.d.ts +1 -0
  128. package/dist/runtime/context/combat.js +2 -1
  129. package/dist/runtime/context/deathLoot.d.ts +2 -0
  130. package/dist/runtime/context/deathLoot.js +1 -0
  131. package/dist/runtime/gameContext.js +7 -0
  132. package/dist/runtime/gameContextTypes.d.ts +19 -3
  133. package/dist/runtime/gameRuntime.d.ts +3 -1
  134. package/dist/runtime/gameRuntime.js +4 -0
  135. package/dist/runtime/inputSnapshot.d.ts +6 -0
  136. package/dist/runtime/inputSnapshot.js +10 -0
  137. package/dist/runtime/localPlayers.d.ts +73 -0
  138. package/dist/runtime/localPlayers.js +119 -0
  139. package/dist/runtime/transport.d.ts +9 -0
  140. package/dist/runtime/transport.js +7 -1
  141. package/dist/scene/behaviorRuntime.d.ts +12 -2
  142. package/dist/scene/behaviorRuntime.js +30 -14
  143. package/dist/scene/behaviors.d.ts +5 -1
  144. package/dist/scene/sceneKinds.d.ts +9 -0
  145. package/dist/scene/vehicleSeat.d.ts +5 -1
  146. package/dist/scene/vehicleSeat.js +5 -1
  147. package/dist/sensor/concealment.d.ts +6 -0
  148. package/dist/sensor/concealment.js +8 -0
  149. package/dist/sensor/freezeMonitor.d.ts +6 -0
  150. package/dist/sensor/freezeMonitor.js +8 -0
  151. package/dist/sensor/perception.d.ts +11 -1
  152. package/dist/sensor/perception.js +57 -10
  153. package/dist/sensor/recordingBuffer.d.ts +3 -0
  154. package/dist/sensor/recordingBuffer.js +8 -0
  155. package/dist/session/roundState.d.ts +4 -0
  156. package/dist/session/roundState.js +11 -0
  157. package/dist/settings/frameRateLimit.d.ts +16 -0
  158. package/dist/settings/frameRateLimit.js +37 -0
  159. package/dist/settings/graphicsProfile.d.ts +2 -0
  160. package/dist/settings/graphicsProfile.js +12 -0
  161. package/dist/settings/graphicsSettings.d.ts +40 -0
  162. package/dist/settings/graphicsSettings.js +81 -0
  163. package/dist/settings/settingsModel.d.ts +9 -1
  164. package/dist/settings/settingsModel.js +10 -0
  165. package/dist/stats/eventMeter.d.ts +1 -1
  166. package/dist/stats/eventMeter.js +1 -1
  167. package/dist/vfx/feedbackMixer.d.ts +61 -0
  168. package/dist/vfx/feedbackMixer.js +106 -0
  169. package/dist/visibility/simulationCulling.d.ts +8 -1
  170. package/dist/visibility/simulationCulling.js +7 -0
  171. package/dist/visibility/spatialIndex.d.ts +21 -0
  172. package/dist/visibility/spatialIndex.js +16 -0
  173. package/dist/world/authoredSolids.d.ts +46 -0
  174. package/dist/world/authoredSolids.js +75 -0
  175. package/dist/world/buildingGenerator.js +2 -2
  176. package/dist/world/buildingKit.d.ts +3 -1
  177. package/dist/world/buildings.d.ts +34 -2
  178. package/dist/world/buildings.js +11 -1
  179. package/dist/world/cityKind.d.ts +7 -0
  180. package/dist/world/cityKind.js +52 -1
  181. package/dist/world/features.d.ts +29 -2
  182. package/dist/world/features.js +6 -0
  183. package/dist/world/footprintGrid.d.ts +7 -0
  184. package/dist/world/footprintGrid.js +16 -0
  185. package/dist/world/interiors.d.ts +1 -0
  186. package/dist/world/interiors.js +5 -3
  187. package/dist/world/lod.d.ts +6 -0
  188. package/dist/world/lod.js +8 -0
  189. package/dist/world/terraform.d.ts +8 -0
  190. package/dist/world/terraform.js +7 -0
  191. package/dist/world/walls.d.ts +7 -0
  192. package/dist/world/walls.js +7 -0
  193. package/dist/world/worldSolids.d.ts +84 -0
  194. package/dist/world/worldSolids.js +240 -0
  195. package/dist/world.d.ts +2 -2
  196. package/dist/world.js +1 -1
  197. package/package.json +1 -1
  198. package/skills/ce-handoff/SKILL.md +1 -1
  199. package/skills/game-design/SKILL.md +2 -2
  200. package/skills/jgengine/SKILL.md +7 -5
  201. package/skills/jgengine/api.md +118 -89
  202. package/skills/jgengine/capabilities.md +28 -0
  203. package/skills/jgengine/recipes/minimal-game.md +3 -0
  204. package/skills/jgengine-combat/SKILL.md +5 -0
  205. package/skills/jgengine-combat/api.md +138 -103
  206. package/skills/jgengine-combat/capabilities.md +29 -1
  207. package/skills/jgengine-combat/recipes/weapon-feel.md +50 -0
  208. package/skills/jgengine-gameplay/api.md +462 -436
  209. package/skills/jgengine-gameplay/capabilities.md +24 -0
  210. package/skills/jgengine-gameplay/reference-systems.md +4 -0
  211. package/skills/jgengine-world/SKILL.md +15 -3
  212. package/skills/jgengine-world/api.md +873 -626
  213. package/skills/jgengine-world/capabilities.md +130 -6
  214. package/skills/jgengine-world/recipes/character-animation.md +39 -0
  215. package/skills/jgengine-world/recipes/controls.md +44 -0
  216. package/skills/jgengine-world/recipes/flight-feel.md +76 -0
  217. package/skills/jgengine-world/recipes/npc-senses.md +43 -0
  218. package/skills/jgengine-world/recipes/vehicle-feel.md +85 -0
  219. package/skills/jgengine-world/reference.md +31 -2
  220. package/skills/jgengine-world/visibility.md +112 -0
@@ -45,7 +45,7 @@ editor/preset-written scene data (`environmentContentFromDocument`), not a world
45
45
 
46
46
  `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
47
 
48
- **Default look is cinematic (#773).** A 3D game gets a real sky + shadow-casting sun/hemisphere rig + tuned post stack out of the box (`look` unset ⇒ `"cinematic"`; `defineGame({ look: "flat" })` opts back to the bare rig). 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".
48
+ **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".
49
49
 
50
50
  **Flatten-mask composition semantics** (`withFlattenMasks` — easy to fight if guessed): masks apply **sequentially and the last matching mask wins outright**; there is no mask-to-mask blending. Inside `radius` the height becomes that mask's `target` exactly; in the `radius..radius+falloff` annulus it lerps from `target` back to the **raw noise height**, never to another mask's output. `target` defaults to the raw noise height at the mask's own center (`height` overrides). Consequences to know before composing roads or multi-site layouts: (1) a chain of default-height circles stair-steps, because each snaps to its own center's noise — give road masks explicit `height` values ramped between the endpoint site targets; (2) a later mask's falloff annulus punches raw-noise bumps through an earlier flat area — order masks so the surface that must win comes last, keep road `falloff` small (≤ radius), and skip road circles inside an already-flat site radius; (3) raw noise rarely herds — to guarantee canyon walls, emit masks with `height` *above* the surroundings flanking the corridor. A working road-ramp recipe (overlapping circles every ~12 units, height ramped between site targets, ramp clamped flat inside each site radius, wall masks alongside) lives in `Games/the-robots/src/game/world/level.ts`.
51
51
 
@@ -109,6 +109,15 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
109
109
  | `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
110
  | `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
111
 
112
+ ### World solids: collision for generated and studio geometry
113
+
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.
115
+
116
+ - `environment({ structures: building(...) })` fills the `environment:structures` layer at context creation. `building({ solid: false })` keeps a set render-only.
117
+ - 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
+ - For code-built geometry, derive solids from data and `set` a layer: `buildingSolids(buildings, groundHeight)`, `wallSolids(wallSegments(points, closed), { height, thickness })`.
119
+ - A yawed solid reaches walkers as AABB strips along its long axis (`obstacleFromSolid`); physics backends get the exact oriented box.
120
+
112
121
  ### Physics world (optional, headless)
113
122
 
114
123
  `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`).
@@ -134,9 +143,17 @@ Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/F
134
143
  **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.
135
144
  ### Vehicles, mounts, crash damage & racing
136
145
  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.
137
147
  - **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).
138
148
  - **`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.
139
- - **`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. Racers and open-world cars that don't need real rigid-body collision.
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`.
150
+ - **`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
+ - **`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
+ - **`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).
153
+ - **`physics/boatDynamics`.** `createBoatDynamics(tuning, { waterHeight?, current? })` is the powered hull on the same step contract as `vehicleDynamics`: throttle drives the prop, brake reverses it, steer swings a `rudder` (lift from flow, so no turning at rest without prop wash) or an `outboard` (swings its thrust, turns at rest). Drag humps at hull speed; `planingSpeed` lets the hull climb over it and sheds keel grip so it turns more freely. The step carries `planing` and `engineLoad` for wake, spray and engine sound; `snapshot`/`restore`/`retune` as usual, and `measureHandling` reads it directly.
154
+ - **`physics/aircraftDynamics`.** `createRigidAircraft(tuning, { wind?, groundHeight?, gravityField?, airDensity? })` is the force-and-torque aircraft: a quaternion rigid body with an inertia tensor (kg·m²), pushed by lifting `surfaces` (area, lift slope, stall angle, post-stall lift, drag polar `cd0` + `k·CL²`, placed at a body-frame point), an `engine` with spool, fuselage `dragArea` and optional `gear`. Controls deflect surfaces through rate-limited actuators (`control: { pitch, roll, yaw }` is AoA change per rad, signed from the lever arm), so loops, rolls, stalls and weathervaning come from where the surfaces sit. The step reports attitude (`heading`/`pitch`/`bank`, quaternion), body rates, `airspeed`, `angleOfAttack`, `sideslip`, `gLoad`, `stalled`; `snapshot`/`restore`/`retune` and per-tick `modifiers` as usual. A `rotor` block makes it a helicopter: collective thrust with rotor spool, cyclic disc tilt, drag torque that yaws the body unless a `tail` rotor cancels it, translational lift and ground effect from momentum theory, with `step.rotor` telemetry. A `motor` block makes it a rocket: a thrust curve over burn time, propellant that leaves at a mass flow so mass and inertia fall through the burn, a gimballed nozzle, and staging by `retune` to the next stage. An `assists` block drives the same actuators: per-axis SAS with attitude and heading hold, auto-level, AoA and g limiters, helicopter hover hold, and a `policy` callback that has the last word. `measureFlight(create, options?)` in `physics/handlingProbe` returns `rollRateDeg`, `sustainedTurnRateDeg`, `stallSpeed`, `climbRate`, `throttleResponse` and `hoverDriftMeters` for tests. Tune it with [recipes/flight-feel.md](recipes/flight-feel.md).
155
+ - **`physics/vehicleBackendLink`.** `createVehicleBackendLink(backend, { halfExtents, blockMask? })` puts a vehicle sim into a `PhysicsBackend` world while the sim keeps owning handling. Pass `link.clampMove` as the sim's `clampMove`, so walls and parked cars stop it and it slides along them. Call `link.sync(step)` after each tick so a kinematic chassis follows the car and shoves dynamic props; put props on a layer outside `blockMask` so they get shoved instead of stopping the car. `lastHit()` reports the wall, normal and blocked distance for crash damage and sound.
156
+ - **`physics/handlingProbe`.** `measureHandling(create, options?)` runs any vehicle sim (`VehicleDynamics` or `KinematicVehicle`) through launch, braking, a steer ramp, a step steer, a lift-off, a handbrake pull and full-throttle-full-steer, and returns deterministic feel metrics (`zeroTo100`, `maxLateralG`, `turnIn`, `stepPeakSideslipDeg`, `spun`, `powerSteerSpun`, …) for tests. `measureCourse(create, { wheelbase })` drives a steer ramp, a skidpad and a slalom with simple deterministic drivers and returns `understeerGradient` (deg/g), `skidpadG` and `slalomSpeed`, and `measureHandling` also reports `handbrakeRecoverySeconds`. A `lean` block makes the sim a motorcycle: steer asks for a lean, the bar steers the balanced turn with a countersteer tip-in, and the step reports `lean` (balance lean) plus `wheelie`/`stoppie`; `measureLean` gives steady lean, time to lean and counter-lean. `measureAir(create)` reports jump apex height and time, double-jump height and air rotation rates for a car with `jump`/`air` blocks. `measureRide(create)` does the same for a sprung car (`suspension` block): `settleSeconds`, `heaveOvershoot`, `brakeDiveDeg`, `rollGradient` (deg/g), `landingBounces`.
140
157
  - **`physics/drivableVehicle`** (#533.1). `tickDrivableVehicle(vehicle, dt, axis, { groundHeight?, modifiers? })` is the missing wire from an `AxisInput` sample through a `KinematicVehicle` to a `setPose`-ready entity patch — the throttle/steer/handbrake → sim → `entities.setPose(id, drive.pose)` loop every drivable-vehicle game hand-rolled, one call instead of a hand-copied per-game `stepCar`. `groundHeight` snaps the resulting `y` to terrain each tick; omit it to keep the sim's flat `y`. Returns `{ pose, step }` — `step` is the raw `KinematicVehicleStep` for HUD speed/slip reads.
141
158
  - **`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.
142
159
  - **`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.
@@ -273,3 +290,15 @@ Path progress adapter (`nav/pathFollow`): `PathProgress` is `{ kind: "normalized
273
290
  Compose, don't replace: scale `MobBrainConfig.aggroRadius` by `perceptionScale`, route `acquireTarget` scoring through `pickScored`, gate `advancePursuit` target swaps behind the reaction gate.
274
291
 
275
292
  **Drive** — `ai/driver` is the vehicle consumer of the profile: `driveStep(state, dt, pose, goal, profile, tuning, rng, obstacleAhead?)` turns a pose + goal into the shared `AxisInput` (`throttle`/`brake`/`steer`) for `tickDrivableVehicle`. The profile decides how well the car drives: the steering target is a delay-line resampled every `reactionSeconds` (easy chasers cut to where the target *was*), corners shed speed instead of orbiting at full throttle, obstacle distance is perceived late by `speed × reactionSeconds`, the wheel wobbles by `executionJitter` (resampled on an interval, so it reads as a loose wheel), and a car grinding a wall backs out with counter-steer after a tier-scaled delay (brake past standstill is the sim's reverse). `createDriverState` is one small serializable object; the caller owns the sim, collision, and all spatial queries. For streets and circuits, `pathTargetAhead(path, x, z, lookahead, loop?)` walks a road/route polyline to the pure-pursuit point ahead — feed it back in as the goal each tick and the same brain follows authored roads; give sharper tiers a longer lookahead.
293
+
294
+ ### Snapshot and restore on movement and visibility handles
295
+
296
+ `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
+
298
+ ### Snapshot and restore on AI, faction and sensor handles
299
+
300
+ 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).
301
+
302
+ ### Snapshot and restore on building handles
303
+
304
+ `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.
@@ -0,0 +1,112 @@
1
+ # Visibility & Streaming
2
+
3
+ Automatic camera-frustum culling, distance culling, spatial partitioning, and asset streaming
4
+ for every game — no per-game setup. A normal object with a position and a version counter is
5
+ culled automatically; you only touch this system to *override* the defaults.
6
+
7
+ This package (`@jgengine/core/visibility/*`) is headless and zero-dependency. The shell wires it
8
+ into the R3F render path (`packages/shell/src/visibility/CullingProvider.tsx`), which reads the
9
+ live camera each frame and toggles `group.visible` so off-screen objects are never submitted to
10
+ the renderer.
11
+
12
+ ## What happens automatically
13
+
14
+ - **Frustum culling** — objects fully outside the active camera's view are not rendered. Uses
15
+ object bounds (sphere + AABB), not origins. Works for 3D perspective and 2D orthographic cameras.
16
+ - **Distance culling** — objects past a max render distance are dropped (square-root-free). The
17
+ default max is `Infinity`, so nothing is culled by distance until a game opts in.
18
+ - **Spatial partitioning** — a uniform 3D hash (`SpatialIndex`); the renderer queries potentially
19
+ visible objects near the frustum instead of scanning the whole scene. Moving objects rewrite only
20
+ the cells that changed; static objects are inserted once.
21
+ - **Bounds caching** — bounds recompute only when an object's `version` advances (transform,
22
+ geometry, or override change), reusing the cached object so steady-state frames allocate nothing.
23
+
24
+ Every game improves immediately after upgrading, because defaults favor visual stability: a preload
25
+ margin larger than the view, hysteresis so objects don't flicker at the boundary, and a large
26
+ default render distance.
27
+
28
+ ## Defaults
29
+
30
+ | Setting | Default | Why |
31
+ | --- | --- | --- |
32
+ | `preloadMargin` (culling) | 24 world units | Preload/consider region beyond the visible frustum |
33
+ | `hysteresis` | 2 world units | Re-test band for already-visible objects (no flicker) |
34
+ | `defaultMaxRenderDistance` | `Infinity` | Never distance-cull unless a game opts in |
35
+ | `occlusionCulling` | `false` | Off until proven reliable for the renderer |
36
+ | streaming `preloadMargin` | 32 | Assets load ahead of visibility |
37
+ | `unloadGraceSeconds` | 10 | Grace period before an idle asset is unloaded |
38
+ | `maxLoadsPerFrame` / `maxUnloadsPerFrame` | 4 / 2 | Per-frame budgets to avoid spikes |
39
+ | `keepResidentBytes` | 64 KiB | Small shared assets stay resident |
40
+
41
+ ## How to opt out / override
42
+
43
+ Set `PlayableGame.visibility`:
44
+
45
+ ```ts
46
+ visibility: {
47
+ enabled: false, // disable render culling entirely
48
+ culling: { defaultMaxRenderDistance: 200 },
49
+ scene: { preloadMargin: 60 }, // scene-wide override
50
+ entities: { boss: { alwaysVisible: true } }, // by entity kind
51
+ objects: { beacon: { neverUnload: true } }, // by object catalog id
52
+ }
53
+ ```
54
+
55
+ Override precedence (earlier wins): **per-object → per-layer → per-scene → global**. Available
56
+ controls: `alwaysVisible`, `neverUnload`, `minRenderDistance`, `maxRenderDistance`, `preloadMargin`,
57
+ `cullingDisabled`, `streamingDisabled`, `classification` (`static`/`dynamic`), `bounds` (custom),
58
+ `customVisibility` (callback), `pinned`.
59
+
60
+ ## Override object bounds
61
+
62
+ Pass a `bounds` override — `sphere`, `aabb`, `rect` (2D), or `point`, each with an optional `offset`:
63
+
64
+ ```ts
65
+ entities: { tower: { bounds: { kind: "aabb", half: [2, 8, 2], offset: [0, 8, 0] } } }
66
+ ```
67
+
68
+ ## Pin assets
69
+
70
+ An asset is never auto-unloaded when it is `pinned`, retained (shared by an active object), or
71
+ required by the current scene. `AssetStreamingSystem.pin(id)` / `retain(id)` from game code; or set
72
+ `neverUnload` / `pinned` on the object override so its assets stay resident.
73
+
74
+ ## Multiple cameras
75
+
76
+ The `VisibilitySystem` unions visibility across every active `CameraVisibilityContext` — split-screen,
77
+ render-to-texture, editor, and minimap cameras all keep an object renderable if any of them needs it.
78
+ A camera can set `influencesStreaming: false` to stay out of asset preloading (e.g. a minimap that
79
+ only needs positions), or `cullingDisabled: true` to render everything.
80
+
81
+ ## Render culling vs asset streaming vs simulation culling
82
+
83
+ - **Render culling** decides what is *drawn*. It never destroys entities or disables simulation.
84
+ - **Asset streaming** decides what is *loaded*. An object may be loaded but not rendered, or inside
85
+ the preload region but outside the render region.
86
+ - **Simulation culling** (`createSimulationCuller`, opt-in, off by default) decides whether a
87
+ low-priority off-screen entity *updates* this tick. It never throttles a protected entity
88
+ (physics/network/audio/scripted/active). Gameplay correctness must not depend on visibility.
89
+
90
+ ## Diagnostics
91
+
92
+ `VisibilitySystem.stats()` reports total objects, considered/rejected counts (frustum, distance,
93
+ occlusion), draw calls avoided, asset queue/loading/loaded/unloaded counts, streamed bytes, and
94
+ spatial-query / culling timings. `debugSnapshot()` returns camera frustum corners, object bounds,
95
+ spatial partitions, culled ids, and streaming state for a debug overlay to draw.
96
+
97
+ ## Architecture
98
+
99
+ `BoundsCache` · `SpatialIndex` · `frustum` (view volumes) · `distance` · `OcclusionTester` ·
100
+ `CullingSettings`/`StreamingSettings` (+ layered `resolveOverrides`) · `CameraVisibilityContext` ·
101
+ `VisibilitySystem` (orchestrator + debug snapshot) · `AssetStreamingSystem` · `SimulationCuller`.
102
+
103
+ ## Known limitations
104
+
105
+ - **Occlusion culling** ships as an interface plus a conservative bounding-volume tester, disabled
106
+ by default. It is not a hierarchical-Z implementation; enable only where large blockers exist.
107
+ - **Instanced batches** in the shell (`world/Instanced*`, `SpriteBatch`) set `frustumCulled={false}`
108
+ and are not yet per-instance culled by this system — a documented future extension.
109
+ - **Asset-streaming shell wiring**: the engine `AssetStreamingSystem` is complete and tested, but the
110
+ shell's R3F loaders still load on mount; wiring streaming into R3F suspension is the next seam.
111
+ - The spatial index partitions on all three axes; extremely oversized objects fall back to an
112
+ always-considered bucket by design.