@jgengine/shell 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +11 -0
  3. package/dist/GamePlayerShell.d.ts +5 -2
  4. package/dist/GamePlayerShell.js +358 -98
  5. package/dist/GameUiPreview.js +2 -1
  6. package/dist/audio/AudioComponents.js +25 -8
  7. package/dist/audio/audioEngine.d.ts +1 -0
  8. package/dist/audio/audioEngine.js +4 -0
  9. package/dist/behaviour.d.ts +5 -24
  10. package/dist/behaviour.js +14 -50
  11. package/dist/behaviourAttach.d.ts +9 -0
  12. package/dist/behaviourAttach.js +32 -0
  13. package/dist/behaviourDriver.d.ts +7 -0
  14. package/dist/behaviourDriver.js +21 -0
  15. package/dist/camera/GameCameraRig.js +1 -1
  16. package/dist/camera/GameFirstPersonCamera.js +14 -0
  17. package/dist/camera/GameOrbitCamera.js +10 -1
  18. package/dist/camera/PlayerFov.d.ts +18 -0
  19. package/dist/camera/PlayerFov.js +48 -0
  20. package/dist/camera/cameraBlendMath.d.ts +13 -0
  21. package/dist/camera/cameraBlendMath.js +36 -0
  22. package/dist/camera/cameraRigs.d.ts +3 -0
  23. package/dist/camera/cameraRigs.js +36 -33
  24. package/dist/camera/fovPreference.d.ts +25 -0
  25. package/dist/camera/fovPreference.js +73 -0
  26. package/dist/camera/index.d.ts +3 -0
  27. package/dist/camera/index.js +3 -0
  28. package/dist/camera/rigMath.d.ts +10 -0
  29. package/dist/camera/rigMath.js +36 -1
  30. package/dist/camera/shakeChannel.d.ts +3 -17
  31. package/dist/camera/shakeChannel.js +2 -21
  32. package/dist/camera/shakeChannelMath.d.ts +8 -0
  33. package/dist/camera/shakeChannelMath.js +21 -0
  34. package/dist/cartridge.d.ts +145 -0
  35. package/dist/cartridge.js +245 -0
  36. package/dist/defineGame.js +2 -1
  37. package/dist/devtools/CollisionDebugWorld.d.ts +5 -0
  38. package/dist/devtools/CollisionDebugWorld.js +180 -0
  39. package/dist/devtools/DevtoolsOverlay.d.ts +65 -1
  40. package/dist/devtools/DevtoolsOverlay.js +383 -41
  41. package/dist/devtools/collisionDebug.d.ts +57 -0
  42. package/dist/devtools/collisionDebug.js +127 -0
  43. package/dist/devtools/collisionDebugMath.d.ts +102 -0
  44. package/dist/devtools/collisionDebugMath.js +128 -0
  45. package/dist/environment/Daylight.d.ts +13 -7
  46. package/dist/environment/Daylight.js +12 -11
  47. package/dist/environment/EnvironmentScene.js +82 -35
  48. package/dist/environment/groundPadMath.d.ts +2 -2
  49. package/dist/environment/groundPadMath.js +1 -1
  50. package/dist/environment/index.d.ts +2 -1
  51. package/dist/environment/index.js +1 -0
  52. package/dist/environment/skyLightingPolicy.d.ts +10 -0
  53. package/dist/environment/skyLightingPolicy.js +6 -0
  54. package/dist/input/mouseLook.d.ts +27 -0
  55. package/dist/input/mouseLook.js +35 -0
  56. package/dist/materialOverride.d.ts +4 -6
  57. package/dist/materialOverride.js +12 -16
  58. package/dist/pointer/PointerProbe.js +6 -2
  59. package/dist/pointer/pointerService.d.ts +3 -0
  60. package/dist/pointer/pointerService.js +6 -0
  61. package/dist/render/modelRender.d.ts +10 -1
  62. package/dist/render/modelRender.js +32 -5
  63. package/dist/render/resolveModel.d.ts +14 -0
  64. package/dist/render/resolveModel.js +24 -0
  65. package/dist/settings/QuickControls.d.ts +4 -0
  66. package/dist/settings/QuickControls.js +42 -0
  67. package/dist/settings/SettingsChrome.d.ts +1 -0
  68. package/dist/settings/SettingsChrome.js +10 -0
  69. package/dist/settings/SettingsMenu.d.ts +6 -0
  70. package/dist/settings/SettingsMenu.js +148 -0
  71. package/dist/settings/SettingsRuntime.d.ts +11 -0
  72. package/dist/settings/SettingsRuntime.js +19 -0
  73. package/dist/settings/appliedSettings.d.ts +14 -0
  74. package/dist/settings/appliedSettings.js +27 -0
  75. package/dist/settings/settingsController.d.ts +20 -0
  76. package/dist/settings/settingsController.js +165 -0
  77. package/dist/structures/GeneratedBuilding.d.ts +10 -0
  78. package/dist/structures/GeneratedBuilding.js +96 -8
  79. package/dist/structures/index.d.ts +1 -1
  80. package/dist/structures/index.js +1 -1
  81. package/dist/terrain/CarvedTerrain.d.ts +2 -1
  82. package/dist/terrain/CarvedTerrain.js +3 -3
  83. package/dist/terrain/GrassField.d.ts +3 -1
  84. package/dist/terrain/GrassField.js +4 -2
  85. package/dist/terrain/grassBudget.d.ts +3 -0
  86. package/dist/terrain/grassBudget.js +6 -0
  87. package/dist/terrain/grassGeometry.js +1 -1
  88. package/dist/terrain/terrainMath.d.ts +8 -0
  89. package/dist/terrain/terrainMath.js +9 -0
  90. package/dist/touch/OrientationHint.d.ts +3 -0
  91. package/dist/touch/OrientationHint.js +13 -0
  92. package/dist/touch/TouchControlsOverlay.d.ts +17 -1
  93. package/dist/touch/TouchControlsOverlay.js +115 -9
  94. package/dist/visibility/CullingProvider.d.ts +21 -0
  95. package/dist/visibility/CullingProvider.js +134 -0
  96. package/dist/vision/FrustumSensorHud.d.ts +1 -6
  97. package/dist/vision/FrustumSensorHud.js +25 -27
  98. package/dist/vision/frustumSampleEqual.d.ts +2 -0
  99. package/dist/vision/frustumSampleEqual.js +10 -0
  100. package/dist/water/Ocean.js +1 -1
  101. package/dist/water/OceanConfig.d.ts +13 -0
  102. package/dist/water/OceanConfig.js +25 -17
  103. package/dist/water/index.d.ts +1 -1
  104. package/dist/water/index.js +1 -1
  105. package/dist/weather/FireSpreadLayer.js +7 -2
  106. package/dist/weather/RainField.d.ts +3 -1
  107. package/dist/weather/RainField.js +4 -4
  108. package/dist/weather/SnowField.d.ts +3 -1
  109. package/dist/weather/SnowField.js +4 -4
  110. package/dist/weather/fireSpreadPose.d.ts +2 -0
  111. package/dist/weather/fireSpreadPose.js +4 -0
  112. package/dist/weather/weatherMath.d.ts +5 -1
  113. package/dist/weather/weatherMath.js +7 -2
  114. package/dist/world/DataObjects.d.ts +1 -1
  115. package/dist/world/DataObjects.js +3 -1
  116. package/dist/world/SpriteBatch.d.ts +44 -0
  117. package/dist/world/SpriteBatch.js +112 -0
  118. package/dist/world/WorldHud.d.ts +3 -0
  119. package/dist/world/WorldHud.js +89 -42
  120. package/dist/world/entityPose.d.ts +14 -0
  121. package/dist/world/entityPose.js +10 -0
  122. package/dist/world/telegraphPulse.d.ts +1 -0
  123. package/dist/world/telegraphPulse.js +4 -0
  124. package/dist/world/worldBarSamples.d.ts +30 -0
  125. package/dist/world/worldBarSamples.js +51 -0
  126. package/llms.txt +1404 -1143
  127. package/package.json +4 -4
package/llms.txt CHANGED
@@ -1,7 +1,7 @@
1
1
  # @jgengine/shell
2
2
  > Game player shell for JGengine: React Three Fiber canvas, orbit camera, input tracking, HUD mounting, GameUiPreview, and a demo game. Consumers supply a GameRegistry.
3
3
 
4
- Version: 0.8.0
4
+ Version: 0.9.0
5
5
  License: AGPL-3.0-only
6
6
  Repository: https://github.com/Noisemaker111/jgengine
7
7
  Docs: https://jgengine.com
@@ -9,6 +9,34 @@ Imports use deep paths: `@jgengine/shell/<path>`.
9
9
 
10
10
  ## Exported surface
11
11
 
12
+ ### @jgengine/shell/GameHost
13
+
14
+ - GameHost (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer }: GameHostProps): React.JSX.Element
15
+ - GameHostProps (interface): interface GameHostProps
16
+
17
+ ### @jgengine/shell/GamePlayer
18
+
19
+ - GamePlayer (function): function GamePlayer({ gameId, registry, fallbackGameId, loading = null, multiplayer = null }: GamePlayerProps): React.JSX.Element
20
+ - GamePlayerProps (type): type GamePlayerProps = { gameId: string; registry: GameRegistry; fallbackGameId?: string; loading?: ReactNode; multiplayer?: ShellMultiplayer | null; }
21
+
22
+ ### @jgengine/shell/GamePlayerShell
23
+
24
+ - GamePlayerShell (function): function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, poster = false, onContextReady, }: { playable: PlayableGame; multiplayer?: ShellMultiplayer | null; poster?: boolean; /** Called once per boot after onInit/onNewPlayer with the live GameContext — a staging seam for screenshots,…
25
+ - applyMotionImpulses (function): function applyMotionImpulses(currentVelocity: number, batch: MotionIntentBatch | null): number — Applies a pending `MotionIntentBatch` to a vertical velocity: impulses add, then `verticalVelocity` replaces the result outright (#162.4).
26
+ - dispatchBoundAction (function): function dispatchBoundAction(ctx: GameContext, action: string, yaw: number, pitch: number, aim: Aim): void — Resolves and runs the command bound to `action` via the shell's action→command convention (shared by `FrameDriver` and `HudOnlyDriver`).
27
+ - hasEnvironmentTerrain (function): function hasEnvironmentTerrain(world: WorldFeature | undefined): boolean — True when the world is an environment feature with terrain, so the voxel controller should sample its height.
28
+ - heldActionsFor (function): function heldActionsFor(tracker: Pick<ActionStateTracker<string>, "isDown">, actions: readonly string[]): string[] — Actions from `input` currently held down, for `ctx.input.publish` (#164.1); includes reserved movement/jump actions.
29
+ - nearbyObstacles (function): function nearbyObstacles(objects: readonly SceneObject[], center: readonly [number, number, number], radius: number = OBSTACLE_GATHER_RADIUS): CollisionObstacle[] — Placed scene objects within `radius` of `center`, as `CollisionObstacle`s for `resolveObstacleStep` (#162.1).
30
+ - resolvePhysicsTuning (function): function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined — Maps the game's declared `physics` onto the movement controllers' tuning overrides. `PhysicsConfig.gravity` is a signed world acceleration (negative points down, matching every game's config and the Y-up convention), but the controllers integrate `velocityY -= gravityAcceleration * dt` and so expect a positive downward magnitude. Negating here is what keeps a down-pointing gravity pulling the player *down*; passing the signed value straight through flipped the sign and launched airborne players upward instead.
31
+ - resolveWorldSky (function): function resolveWorldSky(world: WorldFeature | undefined): SkyEnvironmentDescriptor | undefined — The world's declared sky, when its world feature is an environment with one (#196.1).
32
+ - shouldFireBoundAction (function): function shouldFireBoundAction(tracker: Pick<ActionStateTracker<string>, "isDown" | "wasPressed">, action: string, input: PlayableGame["game"]["input"], repeatFiredAt: ReadonlyMap<string, number>, now: number): boolean — Whether a bound action should fire this frame: on press, or on repeat interval while held (shared by `FrameDriver` and `HudOnlyDriver`).
33
+
34
+ ### @jgengine/shell/GameUiPreview
35
+
36
+ - GameUiPreview (function): function GameUiPreview({ playable, scenario = defaultUiScenario, }: { playable: PlayableGame; scenario?: UiPreviewScenario; }): React.JSX.Element
37
+ - UiPreviewScenario (type): type UiPreviewScenario = (ctx: GameContext, playable: PlayableGame) => void
38
+ - defaultUiScenario (const): const defaultUiScenario: UiPreviewScenario
39
+
12
40
  ### @jgengine/shell/audio/AudioComponents
13
41
 
14
42
  - AudioListener (function): function AudioListener({ engine }: { engine: AudioEngine }): null
@@ -17,37 +45,33 @@ Imports use deep paths: `@jgengine/shell/<path>`.
17
45
 
18
46
  ### @jgengine/shell/audio/audioEngine
19
47
 
20
- - createAudioEngine (function): function createAudioEngine(config: AudioSceneConfig = {}): AudioEngine
21
- - Vec3 (interface): interface Vec3
22
- - AudioSceneConfig (interface): interface AudioSceneConfig
23
48
  - AudioEmitterHandle (interface): interface AudioEmitterHandle
24
49
  - AudioEngine (interface): interface AudioEngine
50
+ - AudioSceneConfig (interface): interface AudioSceneConfig
51
+ - Vec3 (interface): interface Vec3
52
+ - createAudioEngine (function): function createAudioEngine(config: AudioSceneConfig = {}): AudioEngine
25
53
 
26
54
  ### @jgengine/shell/behaviour
27
55
 
28
- - attachObject3D (function): function attachObject3D<T extends Object3DBehaviour>(world: BehaviourWorld, object: Object3D, behaviour: T, nodeId: string = object.uuid): T — Binds `behaviour` to `object` and attaches it to `world` under `nodeId` (default: the object's uuid). Render hooks are chained onto the object's existing `onBeforeRender`/`onAfterRender` only when actually overridden, and are gated on the behaviour being active; the update lifecycle still flows through `world.update`.
29
- - useBehaviourWorld (function): function useBehaviourWorld(world: BehaviourWorld, scaleDt?: (rawDt: number) => number): void — Bootstraps `world` on mount and dispatches `world.update` every frame at simulation priority. `scaleDt` maps the raw frame delta to the dt behaviours receive — pass the game clock's scaling to keep behaviours on game time, or omit for real-time seconds. Games driving `world.update` themselves from `loop.onTick` should not also render this.
30
- - Object3DBehaviour (class): class Object3DBehaviour extends Behaviour A core `Behaviour` bound to a three.js object. `onBeforeRender`/`onAfterRender` ride the object's own three.js render callbacks, so they only fire for renderable objects (Mesh, Line, Points, Sprite) that are visible and in frustum — attach to the mesh itself, not a parent Group, when you need them.
56
+ - Object3DBehaviour (class): class Object3DBehaviour extends Behaviour
57
+ - attachObject3D (function): function attachObject3D<T extends Object3DBehaviour>(world: BehaviourWorld, object: Object3D, behaviour: T, nodeId: string = object.uuid): T
58
+ - createBehaviourWorldDriver (function): function createBehaviourWorldDriver(world: BehaviourWorld): { start(): void; stop(): void; isRunning(): boolean; step(dt: number): void; }
59
+ - useBehaviourWorld (function): function useBehaviourWorld(world: BehaviourWorld, scaleDt?: (rawDt: number) => number): void — Bootstraps `world` on mount and dispatches `world.update` every frame at simulation priority. Stops driving updates on unmount so remount does not double-step. `scaleDt` maps the raw frame delta to the dt behaviours receive — pass the game clock's scaling to keep behaviours on game time, or omit for real-time seconds. Games driving `world.update` themselves from `loop.onTick` should not also render this.
31
60
 
32
- ### @jgengine/shell/camera/cameraRigs
61
+ ### @jgengine/shell/behaviourAttach
33
62
 
34
- - TopDownRig (function): function TopDownRig(props: RigProps): null
35
- - SideScrollRig (function): function SideScrollRig(props: RigProps): null Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look.
36
- - RtsRig (function): function RtsRig(props: RigProps & { panKeysEnabled?: boolean }): null
37
- - ShoulderRig (function): function ShoulderRig(props: RigProps): null
38
- - LockOnRig (function): function LockOnRig(props: RigProps): null
39
- - ChaseRig (function): function ChaseRig(props: RigProps): null
40
- - ObserverRig (function): function ObserverRig(props: RigProps): null — Detached spectator/photo cam (#120): binds to any entity or fixed point and auto-orbits it, reading no player input at all — the van CCTV / photo-mode / kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
41
- - CinematicRig (function): function CinematicRig(props: RigProps): null
42
- - CAMERA_RIG_FRAME_PRIORITY (const): const CAMERA_RIG_FRAME_PRIORITY: -1
43
- - CAMERA_POST_FRAME_PRIORITY (const): const CAMERA_POST_FRAME_PRIORITY: number
44
- - RigProps (interface): interface RigProps
63
+ - Object3DBehaviour (class): class Object3DBehaviour extends Behaviour
64
+ - attachObject3D (function): function attachObject3D<T extends Object3DBehaviour>(world: BehaviourWorld, object: Object3D, behaviour: T, nodeId: string = object.uuid): T
65
+
66
+ ### @jgengine/shell/behaviourDriver
67
+
68
+ - createBehaviourWorldDriver (function): function createBehaviourWorldDriver(world: BehaviourWorld): { start(): void; stop(): void; isRunning(): boolean; step(dt: number): void; }
45
69
 
46
70
  ### @jgengine/shell/camera/GameCameraRig
47
71
 
48
72
  - GameCameraRig (function): function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, }: GameCameraRigProps): React.JSX.Element
49
- - resolveRigKind (function): function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind — Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least specific: an explicit `rig` field always wins; then `perspective: "first"` (the historical shorthand for `rig: "orbit" | "first"`); then the mere presence of a rig's own config block selects that rig, checked in the fixed order below (#207.8) so a config carrying more than one block resolves deterministically instead of depending on object key order. Set `rig` explicitly to break a tie.
50
73
  - GameCameraRigProps (interface): interface GameCameraRigProps
74
+ - resolveRigKind (function): function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind — Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least specific: an explicit `rig` field always wins; then `perspective: "first"` (the historical shorthand for `rig: "orbit" | "first"`); then the mere presence of a rig's own config block selects that rig, checked in the fixed order below (#207.8) so a config carrying more than one block resolves deterministically instead of depending on object key order. Set `rig` explicitly to break a tie.
51
75
 
52
76
  ### @jgengine/shell/camera/GameFirstPersonCamera
53
77
 
@@ -56,256 +80,375 @@ Imports use deep paths: `@jgengine/shell/<path>`.
56
80
 
57
81
  ### @jgengine/shell/camera/GameInspectionCamera
58
82
 
59
- - GameInspectionCamera (function): function GameInspectionCamera({ config: configPatch }: GameInspectionCameraProps): React.JSX.Element Model-viewer style rig (#207.7): left-drag orbit, middle/right-drag pan, scroll zoom toward a configurable anchor. Orbits a fixed `target`; never reads player/entity state.
83
+ - GameInspectionCamera (function): function GameInspectionCamera({ config: configPatch }: GameInspectionCameraProps): React.JSX.Element — Model-viewer style rig (#207.7): left-drag orbit, middle/right-drag pan, scroll zoom toward a configurable anchor. Orbits a fixed `target`; never reads player/entity state.
60
84
  - GameInspectionCameraProps (interface): interface GameInspectionCameraProps
61
85
 
62
86
  ### @jgengine/shell/camera/GameOrbitCamera
63
87
 
64
- - GameOrbitCamera (function): function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, pointerControls = false, }: GameOrbitCameraProps): React.JSX.Element
65
- - seedOrbitCameraTarget (function): function seedOrbitCameraTarget(camera: Camera, target: Vector3, distance: number, height: number): void — Seed orbit target before controls mount (demo spawn at origin).
66
88
  - CameraFollowListener (type): type CameraFollowListener = (state: CameraFollowState) => void
89
+ - GameOrbitCamera (function): function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, pointerControls = false, }: GameOrbitCameraProps): React.JSX.Element
67
90
  - GameOrbitCameraProps (interface): interface GameOrbitCameraProps
91
+ - seedOrbitCameraTarget (function): function seedOrbitCameraTarget(camera: Camera, target: Vector3, distance: number, height: number): void — Seed orbit target before controls mount (demo spawn at origin).
68
92
 
69
- ### @jgengine/shell/camera/index
93
+ ### @jgengine/shell/camera/PlayerFov
94
+
95
+ - PlayerFovProvider (function): function PlayerFovProvider({ config, orthographic, children, }: { config?: GameCameraConfig; orthographic: boolean; children: ReactNode; }): React.JSX.Element
96
+ - PlayerFovSlider (function): function PlayerFovSlider(): React.JSX.Element | null
97
+ - PlayerFovState (interface): interface PlayerFovState
98
+ - usePlayerFov (function): function usePlayerFov(): PlayerFovState
99
+
100
+ ### @jgengine/shell/camera/cameraBlendMath
101
+
102
+ - CameraBlendScratch (interface): interface CameraBlendScratch
103
+ - applyCameraBlendStep (function): function applyCameraBlendStep(scratch: CameraBlendScratch, camera: Camera, targetFov: number, dt: number): boolean
104
+ - captureCameraBlendFrom (function): function captureCameraBlendFrom(scratch: CameraBlendScratch, position: Vector3, quaternion: Quaternion, fov: number, duration: number): void
105
+ - createCameraBlendScratch (function): function createCameraBlendScratch(Vector3Ctor: new () => Vector3, QuaternionCtor: new () => Quaternion): CameraBlendScratch
106
+
107
+ ### @jgengine/shell/camera/cameraRigs
70
108
 
71
- - GameOrbitCamera (function): function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, pointerControls = false, }: GameOrbitCameraProps): React.JSX.Element
72
- - CameraFollowListener (type): type CameraFollowListener = (state: CameraFollowState) => void
73
- - GameOrbitCameraProps (interface): interface GameOrbitCameraProps
74
- - GameFirstPersonCamera (function): function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, }: GameFirstPersonCameraProps): React.JSX.Element | null
75
- - GameFirstPersonCameraProps (interface): interface GameFirstPersonCameraProps
76
- - GameInspectionCamera (function): function GameInspectionCamera({ config: configPatch }: GameInspectionCameraProps): React.JSX.Element — Model-viewer style rig (#207.7): left-drag orbit, middle/right-drag pan, scroll zoom toward a configurable anchor. Orbits a fixed `target`; never reads player/entity state.
77
- - GameInspectionCameraProps (interface): interface GameInspectionCameraProps
78
- - GameCameraRig (function): function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, }: GameCameraRigProps): React.JSX.Element
79
- - resolveRigKind (function): function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind — Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least specific: an explicit `rig` field always wins; then `perspective: "first"` (the historical shorthand for `rig: "orbit" | "first"`); then the mere presence of a rig's own config block selects that rig, checked in the fixed order below (#207.8) so a config carrying more than one block resolves deterministically instead of depending on object key order. Set `rig` explicitly to break a tie.
80
- - GameCameraRigProps (interface): interface GameCameraRigProps
81
109
  - CAMERA_POST_FRAME_PRIORITY (const): const CAMERA_POST_FRAME_PRIORITY: number
82
110
  - CAMERA_RIG_FRAME_PRIORITY (const): const CAMERA_RIG_FRAME_PRIORITY: -1
111
+ - CameraBlendScratch (interface): interface CameraBlendScratch
83
112
  - ChaseRig (function): function ChaseRig(props: RigProps): null
84
113
  - CinematicRig (function): function CinematicRig(props: RigProps): null
85
114
  - LockOnRig (function): function LockOnRig(props: RigProps): null
86
- - ObserverRig (function): function ObserverRig(props: RigProps): null Detached spectator/photo cam (#120): binds to any entity or fixed point and auto-orbits it, reading no player input at all — the van CCTV / photo-mode / kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
115
+ - ObserverRig (function): function ObserverRig(props: RigProps): null — Detached spectator/photo cam (#120): binds to any entity or fixed point and auto-orbits it, reading no player input at all — the van CCTV / photo-mode / kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
116
+ - RigProps (interface): interface RigProps
87
117
  - RtsRig (function): function RtsRig(props: RigProps & { panKeysEnabled?: boolean }): null
88
118
  - ShoulderRig (function): function ShoulderRig(props: RigProps): null
89
- - SideScrollRig (function): function SideScrollRig(props: RigProps): null Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look.
119
+ - SideScrollRig (function): function SideScrollRig(props: RigProps): null — Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look.
90
120
  - TopDownRig (function): function TopDownRig(props: RigProps): null
91
- - RigProps (interface): interface RigProps
92
- - CameraShakeContext (const): const CameraShakeContext: React.Context<import("/home/runner/work/jgengine/jgengine/packages/shell/src/camera/shakeChannel").CameraShakeChannel>
93
- - cameraShake (function): function cameraShake(amplitude: number, decayPerSecond?: number): void Feed the default camera-shake channel from anywhere (see G7 hitstop cross-cut).
94
- - createCameraShakeChannel (function): function createCameraShakeChannel(defaultDecayPerSecond = 1.6): CameraShakeChannel
95
- - defaultCameraShakeChannel (const): const defaultCameraShakeChannel: CameraShakeChannel — Process-wide default channel. A shell mounts its own channel via `CameraShakeContext`, but game systems that have no React context (e.g. a `loop.onTick` reacting to `entity.died`) can import `cameraShake` and feed the default channel directly.
96
- - useCameraShake (function): function useCameraShake(): CameraShakeChannel — The active rig's shake channel — call `.shake(...)` to add trauma from React UI.
97
- - CameraShakeChannel (interface): interface CameraShakeChannel
98
- - addTrauma (function): function addTrauma(state: TraumaState, amount: number): void — Add trauma (0..1) and clamp; larger hits raise the ceiling toward 1.
99
- - angleDelta (function): function angleDelta(a: number, b: number): number — Shortest signed angular delta from `a` to `b`, wrapped to (-PI, PI].
100
- - blendShoulder (function): function blendShoulder(hip: ResolvedShoulder, ads: ResolvedShoulder, t: number): ResolvedShoulder — Blend two shoulder framings by an ADS factor in [0,1].
101
- - chaseDesiredPosition (function): function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
102
- - chaseLookAt (function): function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Look point ahead of / above a chased vehicle.
103
- - cinematicSample (function): function cinematicSample(keyframes: readonly CameraKeyframe[], elapsed: number, loop: boolean, fallbackFov: number): CinematicSample Sample a keyframe path at `elapsed` seconds. Each keyframe's `duration` is the travel time from the previous keyframe into it; segment easing is per the destination keyframe. Reports `done` once past the final keyframe (unless looping, which wraps `elapsed` into the total path duration).
104
- - clamp (function): function clamp(value: number, min: number, max: number): number
105
- - createTrauma (function): function createTrauma(): TraumaState
106
- - crossfadePose (function): function crossfadePose(from: CameraPose, to: CameraPose, t: number): CameraPose Linear cross-fade between two full camera poses (position, lookAt, fov).
107
- - forwardVector (function): function forwardVector(yaw: number): Vec3
108
- - lerp (function): function lerp(from: number, to: number, blend: number): number
109
- - lockOnPose (function): function lockOnPose(player: Vec3, target: Vec3, config: LockOnCameraConfig | undefined, fov: number): { pose: CameraPose; yaw: number } — Lock-on pose: camera sits behind the player along the player→target vector so the target stays framed. The look point is biased between player and target by `framingBias`.
110
- - observerPose (function): function observerPose(subject: Vec3, angle: number, resolved: ResolvedObserver, fov: number): CameraPose — Detached spectator pose: orbits `subject` at a fixed distance/height, never reading player input.
111
- - resolveChase (function): function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase
112
- - resolveDirectedCamera (function): function resolveDirectedCamera(director: DirectorCameraValues | undefined, staticConfig: StaticCameraValues): ResolvedDirectedCamera — Merges a `CameraDirector` runtime snapshot over the static `GameCameraConfig` (#196.2). `director` omitted, or its fields `undefined`/`null`, is a pure passthrough to `staticConfig` so mounting a director with no active override changes nothing.
113
- - resolveObserver (function): function resolveObserver(config: ObserverCameraConfig | undefined): ResolvedObserver
114
- - resolveShoulder (function): function resolveShoulder(config: ShoulderCameraConfig | undefined, aiming: boolean): ResolvedShoulder
115
- - resolveSideScroll (function): function resolveSideScroll(config: SideScrollCameraConfig | undefined): ResolvedSideScroll
116
- - resolveSideScrollPose (function): function resolveSideScrollPose(entityPos: Vec3, resolved: ResolvedSideScroll, fov: number): CameraPose — Fixed lateral 2.5D follow pose: the camera sits perpendicular to the travel axis at `distance`, above the entity by `height`, and looks at the entity raised by `lookHeight`. Axis "x" watches from +z; axis "z" watches from +x.
117
- - resolveTopDown (function): function resolveTopDown(config: TopDownCameraConfig | undefined): ResolvedTopDown
118
- - rightVector (function): function rightVector(yaw: number): Vec3
119
- - rtsPanKeysConflict (function): function rtsPanKeysConflict(input: ActionCodesMap | undefined): boolean
120
- - seatPose (function): function seatPose(follow: Vec3, yaw: number, local: { x?: number; y?: number; z?: number }, fov: number): CameraPose — World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
121
- - shakeOffset (function): function shakeOffset(state: TraumaState, config: { maxOffset?: number; maxRoll?: number; exponent?: number; frequency?: number; } | undefined): ShakeOffset — Positional + roll shake for the current trauma. Shake magnitude is `trauma^exponent` so small hits stay subtle; the oscillation is deterministic per-seed pseudo-noise (no per-frame RNG, so identical inputs reproduce).
122
- - shoulderPose (function): function shoulderPose(follow: Vec3, yaw: number, pitch: number, sideSign: number, shoulder: ResolvedShoulder): CameraPose — Over-the-shoulder camera pose. The boom sits behind the follow point along `yaw`, raised by `heightOffset`, and pushed sideways by `shoulderOffset` (sign set by `sideSign`: +1 right, -1 left). `pitch` tilts the look point.
123
- - sideScrollFollowBlend (function): function sideScrollFollowBlend(followSmoothing: number, dt: number): number — Frame-rate independent follow blend for the side-scroll rig; `followSmoothing <= 0` hard-locks (blend 1) instead of freezing.
124
- - smoothstep (function): function smoothstep(t: number): number
125
- - smoothYaw (function): function smoothYaw(current: number, desired: number, speed: number, dt: number): number — Exponential yaw smoothing that respects wrap-around.
126
- - speedToFov (function): function speedToFov(speed: number, curve: { base?: number; max?: number; speedForMax?: number } | undefined): number — Speed→FOV curve: FOV climbs from base to max as speed rises to `speedForMax`.
127
- - springArmStep (function): function springArmStep(current: Vec3, desired: Vec3, damping: number, dt: number): Vec3 — Exponential spring-arm approach toward a desired point (frame-rate independent).
128
- - stepTrauma (function): function stepTrauma(state: TraumaState, decayPerSecond: number, dt: number): void — Decay trauma linearly toward 0 and advance the shake clock.
129
- - topDownPose (function): function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose — Camera pose for a fixed top-down / isometric rig. `pitch` is the elevation of the camera→target ray above the ground (PI/2 = straight down); `yaw` is the fixed azimuth (PI/4 reads isometric). Height sets zoom; horizontal boom is derived so the look angle stays constant as height changes.
130
- - yawTo (function): function yawTo(from: Vec3, to: Vec3): number — Yaw that points from `from` toward `to` on the XZ plane (matches shell forward = (sin, cos)).
121
+ - applyCameraBlendStep (function): function applyCameraBlendStep(scratch: CameraBlendScratch, camera: Camera, targetFov: number, dt: number): boolean
122
+ - captureCameraBlendFrom (function): function captureCameraBlendFrom(scratch: CameraBlendScratch, position: Vector3, quaternion: Quaternion, fov: number, duration: number): void
123
+ - createCameraBlendScratch (function): function createCameraBlendScratch(Vector3Ctor: new () => Vector3, QuaternionCtor: new () => Quaternion): CameraBlendScratch
124
+
125
+ ### @jgengine/shell/camera/fovPreference
126
+
127
+ - PLAYER_FOV_DEFAULT (const): const PLAYER_FOV_DEFAULT: any
128
+ - PLAYER_FOV_MAX (const): const PLAYER_FOV_MAX: 120
129
+ - PLAYER_FOV_MIN (const): const PLAYER_FOV_MIN: 40
130
+ - PLAYER_FOV_STORAGE_KEY (const): const PLAYER_FOV_STORAGE_KEY: "jgengine:player-fov"
131
+ - PlayerFovBounds (interface): interface PlayerFovBounds
132
+ - clampPlayerFov (function): function clampPlayerFov(value: unknown, min: number = PLAYER_FOV_MIN, max: number = PLAYER_FOV_MAX): number
133
+ - composePlayerFov (function): function composePlayerFov(preference: number, poseFov: number, mode: "relative" | "absolute" = "relative", bounds: PlayerFovBounds = resolvePlayerFovBounds()): number — Composition model for perspective rigs: - preference is the player base FOV - poseFov is the rig's authored FOV (includes chase-speed modulation, ADS zoom, transitions) - relative mode shifts the authored FOV by (preference default) so mods still stack - absolute mode (cinematic keyframes) keeps the authored FOV as-is
134
+ - loadPlayerFov (function): function loadPlayerFov(bounds: PlayerFovBounds = resolvePlayerFovBounds(), storage: Pick<Storage, "getItem"> | null | undefined = defaultStorage()): number
135
+ - resolvePlayerFovBounds (function): function resolvePlayerFovBounds(options?: { min?: number; max?: number; default?: number; }): PlayerFovBounds
136
+ - savePlayerFov (function): function savePlayerFov(value: number, bounds: PlayerFovBounds = resolvePlayerFovBounds(), storage: Pick<Storage, "setItem"> | null | undefined = defaultStorage()): number
137
+
138
+ ### @jgengine/shell/camera/index
139
+
140
+ - CAMERA_POST_FRAME_PRIORITY (const): const CAMERA_POST_FRAME_PRIORITY: number
141
+ - CAMERA_RIG_FRAME_PRIORITY (const): const CAMERA_RIG_FRAME_PRIORITY: -1
142
+ - CameraBlendScratch (interface): interface CameraBlendScratch
143
+ - CameraFollowListener (type): type CameraFollowListener = (state: CameraFollowState) => void
144
+ - CameraFollowState (interface): interface CameraFollowState
131
145
  - CameraPose (interface): interface CameraPose
146
+ - CameraShakeChannel (interface): interface CameraShakeChannel
147
+ - CameraShakeContext (const): const CameraShakeContext: React.Context<CameraShakeChannel>
148
+ - ChaseRig (function): function ChaseRig(props: RigProps): null
149
+ - CinematicRig (function): function CinematicRig(props: RigProps): null
150
+ - DEFAULT_ORBIT_CAMERA (const): const DEFAULT_ORBIT_CAMERA: ResolvedOrbitCameraConfig
132
151
  - DirectorCameraValues (interface): interface DirectorCameraValues
152
+ - GAME_SIM_FRAME_PRIORITY (const): const GAME_SIM_FRAME_PRIORITY: 0 — Run simulation/movement before orbit follow so poses are current.
153
+ - GameCameraRig (function): function GameCameraRig({ yawRef, pitchRef, config, onDragChange, pointerControls, panKeysEnabled, director, }: GameCameraRigProps): React.JSX.Element
154
+ - GameCameraRigProps (interface): interface GameCameraRigProps
155
+ - GameFirstPersonCamera (function): function GameFirstPersonCamera({ yawRef, pitchRef, config, followEntityId, }: GameFirstPersonCameraProps): React.JSX.Element | null
156
+ - GameFirstPersonCameraProps (interface): interface GameFirstPersonCameraProps
157
+ - GameInspectionCamera (function): function GameInspectionCamera({ config: configPatch }: GameInspectionCameraProps): React.JSX.Element — Model-viewer style rig (#207.7): left-drag orbit, middle/right-drag pan, scroll zoom toward a configurable anchor. Orbits a fixed `target`; never reads player/entity state.
158
+ - GameInspectionCameraProps (interface): interface GameInspectionCameraProps
159
+ - GameOrbitCamera (function): function GameOrbitCamera({ yawRef, pitchRef, config: configPatch, followEntityId, resolveFollowTarget, onDragChange, onCameraFollow, pointerControls = false, }: GameOrbitCameraProps): React.JSX.Element
160
+ - GameOrbitCameraProps (interface): interface GameOrbitCameraProps
161
+ - LockOnRig (function): function LockOnRig(props: RigProps): null
162
+ - ORBIT_CAMERA_FRAME_PRIORITY (const): const ORBIT_CAMERA_FRAME_PRIORITY: -1 — Orbit follow reads the latest entity pose after GAME_SIM_FRAME_PRIORITY.
163
+ - ObserverRig (function): function ObserverRig(props: RigProps): null — Detached spectator/photo cam (#120): binds to any entity or fixed point and auto-orbits it, reading no player input at all — the van CCTV / photo-mode / kill-cam rig. Distinct from every other rig, which drives from mouse/keys.
164
+ - OrbitCameraConfig (interface): interface OrbitCameraConfig
165
+ - OrbitFollowRuntimeState (interface): interface OrbitFollowRuntimeState
166
+ - PLAYER_FOV_DEFAULT (const): const PLAYER_FOV_DEFAULT: any
167
+ - PLAYER_FOV_MAX (const): const PLAYER_FOV_MAX: 120
168
+ - PLAYER_FOV_MIN (const): const PLAYER_FOV_MIN: 40
169
+ - PLAYER_FOV_STORAGE_KEY (const): const PLAYER_FOV_STORAGE_KEY: "jgengine:player-fov"
170
+ - PlayerFovBounds (interface): interface PlayerFovBounds
171
+ - PlayerFovProvider (function): function PlayerFovProvider({ config, orthographic, children, }: { config?: GameCameraConfig; orthographic: boolean; children: ReactNode; }): React.JSX.Element
172
+ - PlayerFovSlider (function): function PlayerFovSlider(): React.JSX.Element | null
173
+ - PlayerFovState (interface): interface PlayerFovState
133
174
  - ResolvedChase (interface): interface ResolvedChase
134
175
  - ResolvedDirectedCamera (interface): interface ResolvedDirectedCamera
176
+ - ResolvedInspectionCameraConfig (interface): interface ResolvedInspectionCameraConfig
135
177
  - ResolvedObserver (interface): interface ResolvedObserver
178
+ - ResolvedOrbitCameraConfig (interface): interface ResolvedOrbitCameraConfig — Fully resolved shell config after merging with DEFAULT_ORBIT_CAMERA.
136
179
  - ResolvedShoulder (interface): interface ResolvedShoulder
137
180
  - ResolvedSideScroll (interface): interface ResolvedSideScroll
138
181
  - ResolvedTopDown (interface): interface ResolvedTopDown
182
+ - RigProps (interface): interface RigProps
183
+ - RtsRig (function): function RtsRig(props: RigProps & { panKeysEnabled?: boolean }): null
139
184
  - ShakeOffset (interface): interface ShakeOffset
185
+ - ShoulderRig (function): function ShoulderRig(props: RigProps): null
186
+ - SideScrollRig (function): function SideScrollRig(props: RigProps): null — Fixed side-on 2.5D follow rig: watches the followed entity from the perpendicular axis, never reading WASD/mouse-look.
140
187
  - StaticCameraValues (interface): interface StaticCameraValues
188
+ - TopDownRig (function): function TopDownRig(props: RigProps): null
141
189
  - TraumaState (interface): interface TraumaState
190
+ - Vec3 (interface): interface Vec3
191
+ - addTrauma (function): function addTrauma(state: TraumaState, amount: number): void — Add trauma (0..1) and clamp; larger hits raise the ceiling toward 1.
192
+ - angleDelta (function): function angleDelta(a: number, b: number): number — Shortest signed angular delta from `a` to `b`, wrapped to (-PI, PI].
193
+ - applyCameraBlendStep (function): function applyCameraBlendStep(scratch: CameraBlendScratch, camera: Camera, targetFov: number, dt: number): boolean
194
+ - blendShoulder (function): function blendShoulder(hip: ResolvedShoulder, ads: ResolvedShoulder, t: number): ResolvedShoulder — Blend two shoulder framings by an ADS factor in [0,1].
142
195
  - cameraFollowStep (function): function cameraFollowStep(input: { camera: Vec3; target: Vec3; previousTarget: Vec3 | null; lockedDistance: number | null; }): CameraFollowState
143
- - cameraLookPitch (function): function cameraLookPitch(camera: Vec3, target: Vec3): number Elevation the camera looks along (radians): negative = aiming down, positive = aiming up, 0 = level. Feeds aim.pitch so vertical aim tracks the camera.
144
- - DEFAULT_ORBIT_CAMERA (const): const DEFAULT_ORBIT_CAMERA: ResolvedOrbitCameraConfig
196
+ - cameraLookPitch (function): function cameraLookPitch(camera: Vec3, target: Vec3): number — Elevation the camera looks along (radians): negative = aiming down, positive = aiming up, 0 = level. Feeds aim.pitch so vertical aim tracks the camera.
197
+ - cameraShake (function): function cameraShake(amplitude: number, decayPerSecond?: number): void — Feed the default camera-shake channel from anywhere (see G7 hitstop cross-cut).
198
+ - captureCameraBlendFrom (function): function captureCameraBlendFrom(scratch: CameraBlendScratch, position: Vector3, quaternion: Quaternion, fov: number, duration: number): void
199
+ - chaseDesiredPosition (function): function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
200
+ - chaseLookAt (function): function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Look point ahead of / above a chased vehicle.
201
+ - cinematicSample (function): function cinematicSample(keyframes: readonly CameraKeyframe[], elapsed: number, loop: boolean, fallbackFov: number): CinematicSample — Sample a keyframe path at `elapsed` seconds. Each keyframe's `duration` is the travel time from the previous keyframe into it; segment easing is per the destination keyframe. Reports `done` once past the final keyframe (unless looping, which wraps `elapsed` into the total path duration).
202
+ - clamp (function): function clamp(value: number, min: number, max: number): number
203
+ - clampPlayerFov (function): function clampPlayerFov(value: unknown, min: number = PLAYER_FOV_MIN, max: number = PLAYER_FOV_MAX): number
204
+ - composePlayerFov (function): function composePlayerFov(preference: number, poseFov: number, mode: "relative" | "absolute" = "relative", bounds: PlayerFovBounds = resolvePlayerFovBounds()): number — Composition model for perspective rigs: - preference is the player base FOV - poseFov is the rig's authored FOV (includes chase-speed modulation, ADS zoom, transitions) - relative mode shifts the authored FOV by (preference − default) so mods still stack - absolute mode (cinematic keyframes) keeps the authored FOV as-is
205
+ - createCameraBlendScratch (function): function createCameraBlendScratch(Vector3Ctor: new () => Vector3, QuaternionCtor: new () => Quaternion): CameraBlendScratch
206
+ - createCameraShakeChannel (function): function createCameraShakeChannel(defaultDecayPerSecond = 1.6): CameraShakeChannel
207
+ - createTrauma (function): function createTrauma(): TraumaState
208
+ - crossfadePose (function): function crossfadePose(from: CameraPose, to: CameraPose, t: number): CameraPose — Linear cross-fade between two full camera poses (position, lookAt, fov).
209
+ - defaultCameraShakeChannel (const): const defaultCameraShakeChannel: CameraShakeChannel — Process-wide default channel. A shell mounts its own channel via `CameraShakeContext`, but game systems that have no React context (e.g. a `loop.onTick` reacting to `entity.died`) can import `cameraShake` and feed the default channel directly.
145
210
  - distanceBetween (function): function distanceBetween(a: Vec3, b: Vec3): number
146
- - GAME_SIM_FRAME_PRIORITY (const): const GAME_SIM_FRAME_PRIORITY: 0 — Run simulation/movement before orbit follow so poses are current.
211
+ - forwardVector (function): function forwardVector(yaw: number): Vec3
212
+ - lerp (function): function lerp(from: number, to: number, blend: number): number
147
213
  - lerpVec3 (function): function lerpVec3(from: Vec3, to: Vec3, blend: number): Vec3
148
- - ORBIT_CAMERA_FRAME_PRIORITY (const): const ORBIT_CAMERA_FRAME_PRIORITY: -1 Orbit follow reads the latest entity pose after GAME_SIM_FRAME_PRIORITY.
149
- - orbitFollowStep (function): function orbitFollowStep(input: { state: OrbitFollowRuntimeState; desiredTarget: Vec3; deltaSeconds: number; config: ResolvedOrbitCameraConfig; dragging: boolean; }): OrbitFollowRuntimeState & { distance: number } Pure orbit follow step smooth target tracking, camera carries target delta (OrbitControls alone keeps camera fixed when only target moves), optional distance lock. Call each frame before OrbitControls.update() in the shell.
150
- - orbitYawFromCamera (function): function orbitYawFromCamera(cameraX: number, cameraZ: number, targetX: number, targetZ: number): number Horizontal facing derived from an orbit camera orbiting a target on the XZ plane.
214
+ - loadPlayerFov (function): function loadPlayerFov(bounds: PlayerFovBounds = resolvePlayerFovBounds(), storage: Pick<Storage, "getItem"> | null | undefined = defaultStorage()): number
215
+ - lockOnPose (function): function lockOnPose(player: Vec3, target: Vec3, config: LockOnCameraConfig | undefined, fov: number): { pose: CameraPose; yaw: number } — Lock-on pose: camera sits behind the player along the player→target vector so the target stays framed. The look point is biased between player and target by `framingBias`.
216
+ - observerPose (function): function observerPose(subject: Vec3, angle: number, resolved: ResolvedObserver, fov: number): CameraPose — Detached spectator pose: orbits `subject` at a fixed distance/height, never reading player input.
217
+ - orbitFollowStep (function): function orbitFollowStep(input: { state: OrbitFollowRuntimeState; desiredTarget: Vec3; deltaSeconds: number; config: ResolvedOrbitCameraConfig; dragging: boolean; }): OrbitFollowRuntimeState & { distance: number } — Pure orbit follow step — smooth target tracking, camera carries target delta (OrbitControls alone keeps camera fixed when only target moves), optional distance lock. Call each frame before OrbitControls.update() in the shell.
218
+ - orbitYawFromCamera (function): function orbitYawFromCamera(cameraX: number, cameraZ: number, targetX: number, targetZ: number): number — Horizontal facing derived from an orbit camera orbiting a target on the XZ plane.
219
+ - resolveChase (function): function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase
220
+ - resolveDirectedCamera (function): function resolveDirectedCamera(director: DirectorCameraValues | undefined, staticConfig: StaticCameraValues): ResolvedDirectedCamera — Merges a `CameraDirector` runtime snapshot over the static `GameCameraConfig` (#196.2). `director` omitted, or its fields `undefined`/`null`, is a pure passthrough to `staticConfig` so mounting a director with no active override changes nothing.
151
221
  - resolveFollowTargetFromPosition (function): function resolveFollowTargetFromPosition(position: readonly [number, number, number], config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset">): Vec3
222
+ - resolveInspectionCameraConfig (function): function resolveInspectionCameraConfig(config?: InspectionCameraConfig): ResolvedInspectionCameraConfig
223
+ - resolveInspectionZoomToCursor (function): function resolveInspectionZoomToCursor(anchor: InspectionZoomAnchor): boolean — Maps the anchor mode onto three-stdlib OrbitControls' native `zoomToCursor` flag.
224
+ - resolveObserver (function): function resolveObserver(config: ObserverCameraConfig | undefined): ResolvedObserver
152
225
  - resolveOrbitCameraConfig (function): function resolveOrbitCameraConfig(patch?: OrbitCameraConfig): ResolvedOrbitCameraConfig
226
+ - resolvePlayerFovBounds (function): function resolvePlayerFovBounds(options?: { min?: number; max?: number; default?: number; }): PlayerFovBounds
227
+ - resolveRigKind (function): function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind — Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least specific: an explicit `rig` field always wins; then `perspective: "first"` (the historical shorthand for `rig: "orbit" | "first"`); then the mere presence of a rig's own config block selects that rig, checked in the fixed order below (#207.8) so a config carrying more than one block resolves deterministically instead of depending on object key order. Set `rig` explicitly to break a tie.
228
+ - resolveShoulder (function): function resolveShoulder(config: ShoulderCameraConfig | undefined, aiming: boolean): ResolvedShoulder
229
+ - resolveSideScroll (function): function resolveSideScroll(config: SideScrollCameraConfig | undefined): ResolvedSideScroll
230
+ - resolveSideScrollPose (function): function resolveSideScrollPose(entityPos: Vec3, resolved: ResolvedSideScroll, fov: number): CameraPose — Fixed lateral 2.5D follow pose: the camera sits perpendicular to the travel axis at `distance`, above the entity by `height`, and looks at the entity raised by `lookHeight`. Axis "x" watches from +z; axis "z" watches from +x.
153
231
  - resolveTargetSmoothing (function): function resolveTargetSmoothing(config: ResolvedOrbitCameraConfig, dragging: boolean): number
232
+ - resolveTopDown (function): function resolveTopDown(config: TopDownCameraConfig | undefined): ResolvedTopDown
233
+ - rightVector (function): function rightVector(yaw: number): Vec3 — Screen-right of a yaw: `forward × up` with up = +Y.
234
+ - rtsPanKeysConflict (function): function rtsPanKeysConflict(input: ActionCodesMap | undefined): boolean
235
+ - savePlayerFov (function): function savePlayerFov(value: number, bounds: PlayerFovBounds = resolvePlayerFovBounds(), storage: Pick<Storage, "setItem"> | null | undefined = defaultStorage()): number
236
+ - seatPose (function): function seatPose(follow: Vec3, yaw: number, local: { x?: number; y?: number; z?: number }, fov: number): CameraPose — World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
237
+ - seedInspectionCamera (function): function seedInspectionCamera(config: ResolvedInspectionCameraConfig): { camera: Vec3; target: Vec3 } — Seeds the camera/target world position before OrbitControls mounts. Falls back to `initialDistance` behind `target` on the -Z axis, raised by 40% of that distance, when `initialPosition` is unset.
154
238
  - seedOrbitFollowState (function): function seedOrbitFollowState(input: { entityPosition: readonly [number, number, number]; config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset" | "initialDistance" | "initialHeight">; }): OrbitFollowRuntimeState
155
- - smoothBlend (function): function smoothBlend(deltaSeconds: number, speed: number): number Frame-rate independent exponential smoothing factor in [0, 1].
156
- - CameraFollowState (interface): interface CameraFollowState
157
- - OrbitCameraConfig (interface): interface OrbitCameraConfig
158
- - OrbitFollowRuntimeState (interface): interface OrbitFollowRuntimeState
159
- - ResolvedOrbitCameraConfig (interface): interface ResolvedOrbitCameraConfig Fully resolved shell config after merging with DEFAULT_ORBIT_CAMERA.
160
- - Vec3 (interface): interface Vec3
161
- - resolveInspectionCameraConfig (function): function resolveInspectionCameraConfig(config?: InspectionCameraConfig): ResolvedInspectionCameraConfig
162
- - resolveInspectionZoomToCursor (function): function resolveInspectionZoomToCursor(anchor: InspectionZoomAnchor): boolean Maps the anchor mode onto three-stdlib OrbitControls' native `zoomToCursor` flag.
163
- - seedInspectionCamera (function): function seedInspectionCamera(config: ResolvedInspectionCameraConfig): { camera: Vec3; target: Vec3 } Seeds the camera/target world position before OrbitControls mounts. Falls back to `initialDistance` behind `target` on the -Z axis, raised by 40% of that distance, when `initialPosition` is unset.
164
- - ResolvedInspectionCameraConfig (interface): interface ResolvedInspectionCameraConfig
239
+ - shakeOffset (function): function shakeOffset(state: TraumaState, config: { maxOffset?: number; maxRoll?: number; exponent?: number; frequency?: number; } | undefined): ShakeOffset — Positional + roll shake for the current trauma. Shake magnitude is `trauma^exponent` so small hits stay subtle; the oscillation is deterministic per-seed pseudo-noise (no per-frame RNG, so identical inputs reproduce).
240
+ - shoulderPose (function): function shoulderPose(follow: Vec3, yaw: number, pitch: number, sideSign: number, shoulder: ResolvedShoulder): CameraPose — Over-the-shoulder camera pose. The boom sits behind the follow point along `yaw`, raised by `heightOffset`, and pushed sideways by `shoulderOffset` (sign set by `sideSign`: +1 right, -1 left). `pitch` tilts the look point.
241
+ - sideScrollFollowBlend (function): function sideScrollFollowBlend(followSmoothing: number, dt: number): number — Frame-rate independent follow blend for the side-scroll rig; `followSmoothing <= 0` hard-locks (blend 1) instead of freezing.
242
+ - smoothBlend (function): function smoothBlend(deltaSeconds: number, speed: number): number — Frame-rate independent exponential smoothing factor in [0, 1].
243
+ - smoothYaw (function): function smoothYaw(current: number, desired: number, speed: number, dt: number): number — Exponential yaw smoothing that respects wrap-around.
244
+ - smoothstep (function): function smoothstep(t: number): number
245
+ - speedToFov (function): function speedToFov(speed: number, curve: { base?: number; max?: number; speedForMax?: number } | undefined): number — Speed→FOV curve: FOV climbs from base to max as speed rises to `speedForMax`.
246
+ - springArmStep (function): function springArmStep(current: Vec3, desired: Vec3, damping: number, dt: number): Vec3 — Exponential spring-arm approach toward a desired point (frame-rate independent).
247
+ - stepTrauma (function): function stepTrauma(state: TraumaState, decayPerSecond: number, dt: number): void — Decay trauma linearly toward 0 and advance the shake clock.
248
+ - topDownPose (function): function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose — Camera pose for a fixed top-down / isometric rig. `pitch` is the elevation of the camera→target ray above the ground (PI/2 = straight down); `yaw` is the fixed azimuth (PI/4 reads isometric). Height sets zoom; horizontal boom is derived so the look angle stays constant as height changes.
249
+ - useCameraShake (function): function useCameraShake(): CameraShakeChannel — The active rig's shake channel — call `.shake(...)` to add trauma from React UI.
250
+ - usePlayerFov (function): function usePlayerFov(): PlayerFovState
251
+ - yawTo (function): function yawTo(from: Vec3, to: Vec3): number — Yaw that points from `from` toward `to` on the XZ plane (matches shell forward = (sin, cos)).
165
252
 
166
253
  ### @jgengine/shell/camera/inspectionCameraMath
167
254
 
168
- - resolveInspectionCameraConfig (function): function resolveInspectionCameraConfig(config?: InspectionCameraConfig): ResolvedInspectionCameraConfig
169
- - seedInspectionCamera (function): function seedInspectionCamera(config: ResolvedInspectionCameraConfig): { camera: Vec3; target: Vec3 } — Seeds the camera/target world position before OrbitControls mounts. Falls back to `initialDistance` behind `target` on the -Z axis, raised by 40% of that distance, when `initialPosition` is unset.
170
- - resolveInspectionZoomToCursor (function): function resolveInspectionZoomToCursor(anchor: InspectionZoomAnchor): boolean — Maps the anchor mode onto three-stdlib OrbitControls' native `zoomToCursor` flag.
171
255
  - ResolvedInspectionCameraConfig (interface): interface ResolvedInspectionCameraConfig
256
+ - resolveInspectionCameraConfig (function): function resolveInspectionCameraConfig(config?: InspectionCameraConfig): ResolvedInspectionCameraConfig
257
+ - resolveInspectionZoomToCursor (function): function resolveInspectionZoomToCursor(anchor: InspectionZoomAnchor): boolean — Maps the anchor mode onto three-stdlib OrbitControls' native `zoomToCursor` flag.
258
+ - seedInspectionCamera (function): function seedInspectionCamera(config: ResolvedInspectionCameraConfig): { camera: Vec3; target: Vec3 } — Seeds the camera/target world position before OrbitControls mounts. Falls back to `initialDistance` behind `target` on the -Z axis, raised by 40% of that distance, when `initialPosition` is unset.
172
259
 
173
260
  ### @jgengine/shell/camera/orbitCameraMath
174
261
 
175
- - orbitYawFromCamera (function): function orbitYawFromCamera(cameraX: number, cameraZ: number, targetX: number, targetZ: number): number — Horizontal facing derived from an orbit camera orbiting a target on the XZ plane.
176
- - cameraLookPitch (function): function cameraLookPitch(camera: Vec3, target: Vec3): number — Elevation the camera looks along (radians): negative = aiming down, positive = aiming up, 0 = level. Feeds aim.pitch so vertical aim tracks the camera.
177
- - smoothBlend (function): function smoothBlend(deltaSeconds: number, speed: number): number — Frame-rate independent exponential smoothing factor in [0, 1].
178
- - resolveOrbitCameraConfig (function): function resolveOrbitCameraConfig(patch?: OrbitCameraConfig): ResolvedOrbitCameraConfig
179
- - resolveTargetSmoothing (function): function resolveTargetSmoothing(config: ResolvedOrbitCameraConfig, dragging: boolean): number
180
- - resolveFollowTargetFromPosition (function): function resolveFollowTargetFromPosition(position: readonly [number, number, number], config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset">): Vec3
181
- - lerpVec3 (function): function lerpVec3(from: Vec3, to: Vec3, blend: number): Vec3
182
- - distanceBetween (function): function distanceBetween(a: Vec3, b: Vec3): number
183
- - seedOrbitFollowState (function): function seedOrbitFollowState(input: { entityPosition: readonly [number, number, number]; config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset" | "initialDistance" | "initialHeight">; }): OrbitFollowRuntimeState
184
- - orbitFollowStep (function): function orbitFollowStep(input: { state: OrbitFollowRuntimeState; desiredTarget: Vec3; deltaSeconds: number; config: ResolvedOrbitCameraConfig; dragging: boolean; }): OrbitFollowRuntimeState & { distance: number } — Pure orbit follow step — smooth target tracking, camera carries target delta (OrbitControls alone keeps camera fixed when only target moves), optional distance lock. Call each frame before OrbitControls.update() in the shell.
185
- - cameraFollowStep (function): function cameraFollowStep(input: { camera: Vec3; target: Vec3; previousTarget: Vec3 | null; lockedDistance: number | null; }): CameraFollowState
186
- - Vec3 (interface): interface Vec3
187
262
  - CameraFollowState (interface): interface CameraFollowState
188
- - OrbitCameraConfig (interface): interface OrbitCameraConfig
189
- - ResolvedOrbitCameraConfig (interface): interface ResolvedOrbitCameraConfig — Fully resolved shell config after merging with DEFAULT_ORBIT_CAMERA.
190
263
  - DEFAULT_ORBIT_CAMERA (const): const DEFAULT_ORBIT_CAMERA: ResolvedOrbitCameraConfig
191
- - GAME_SIM_FRAME_PRIORITY (const): const GAME_SIM_FRAME_PRIORITY: 0 Run simulation/movement before orbit follow so poses are current.
192
- - ORBIT_CAMERA_FRAME_PRIORITY (const): const ORBIT_CAMERA_FRAME_PRIORITY: -1 Orbit follow reads the latest entity pose after GAME_SIM_FRAME_PRIORITY.
264
+ - GAME_SIM_FRAME_PRIORITY (const): const GAME_SIM_FRAME_PRIORITY: 0 — Run simulation/movement before orbit follow so poses are current.
265
+ - ORBIT_CAMERA_FRAME_PRIORITY (const): const ORBIT_CAMERA_FRAME_PRIORITY: -1 — Orbit follow reads the latest entity pose after GAME_SIM_FRAME_PRIORITY.
266
+ - OrbitCameraConfig (interface): interface OrbitCameraConfig
193
267
  - OrbitFollowRuntimeState (interface): interface OrbitFollowRuntimeState
268
+ - ResolvedOrbitCameraConfig (interface): interface ResolvedOrbitCameraConfig — Fully resolved shell config after merging with DEFAULT_ORBIT_CAMERA.
269
+ - Vec3 (interface): interface Vec3
270
+ - cameraFollowStep (function): function cameraFollowStep(input: { camera: Vec3; target: Vec3; previousTarget: Vec3 | null; lockedDistance: number | null; }): CameraFollowState
271
+ - cameraLookPitch (function): function cameraLookPitch(camera: Vec3, target: Vec3): number — Elevation the camera looks along (radians): negative = aiming down, positive = aiming up, 0 = level. Feeds aim.pitch so vertical aim tracks the camera.
272
+ - distanceBetween (function): function distanceBetween(a: Vec3, b: Vec3): number
273
+ - lerpVec3 (function): function lerpVec3(from: Vec3, to: Vec3, blend: number): Vec3
274
+ - orbitFollowStep (function): function orbitFollowStep(input: { state: OrbitFollowRuntimeState; desiredTarget: Vec3; deltaSeconds: number; config: ResolvedOrbitCameraConfig; dragging: boolean; }): OrbitFollowRuntimeState & { distance: number } — Pure orbit follow step — smooth target tracking, camera carries target delta (OrbitControls alone keeps camera fixed when only target moves), optional distance lock. Call each frame before OrbitControls.update() in the shell.
275
+ - orbitYawFromCamera (function): function orbitYawFromCamera(cameraX: number, cameraZ: number, targetX: number, targetZ: number): number — Horizontal facing derived from an orbit camera orbiting a target on the XZ plane.
276
+ - resolveFollowTargetFromPosition (function): function resolveFollowTargetFromPosition(position: readonly [number, number, number], config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset">): Vec3
277
+ - resolveOrbitCameraConfig (function): function resolveOrbitCameraConfig(patch?: OrbitCameraConfig): ResolvedOrbitCameraConfig
278
+ - resolveTargetSmoothing (function): function resolveTargetSmoothing(config: ResolvedOrbitCameraConfig, dragging: boolean): number
279
+ - seedOrbitFollowState (function): function seedOrbitFollowState(input: { entityPosition: readonly [number, number, number]; config: Pick<ResolvedOrbitCameraConfig, "targetHeight" | "targetOffset" | "initialDistance" | "initialHeight">; }): OrbitFollowRuntimeState
280
+ - smoothBlend (function): function smoothBlend(deltaSeconds: number, speed: number): number — Frame-rate independent exponential smoothing factor in [0, 1].
194
281
 
195
282
  ### @jgengine/shell/camera/rigMath
196
283
 
197
- - rtsPanKeysConflict (function): function rtsPanKeysConflict(input: ActionCodesMap | undefined): boolean
198
- - clamp (function): function clamp(value: number, min: number, max: number): number
199
- - lerp (function): function lerp(from: number, to: number, blend: number): number
200
- - smoothstep (function): function smoothstep(t: number): number
201
- - forwardVector (function): function forwardVector(yaw: number): Vec3
202
- - rightVector (function): function rightVector(yaw: number): Vec3
203
- - resolveTopDown (function): function resolveTopDown(config: TopDownCameraConfig | undefined): ResolvedTopDown
204
- - topDownPose (function): function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose — Camera pose for a fixed top-down / isometric rig. `pitch` is the elevation of the camera→target ray above the ground (PI/2 = straight down); `yaw` is the fixed azimuth (PI/4 reads isometric). Height sets zoom; horizontal boom is derived so the look angle stays constant as height changes.
205
- - springArmStep (function): function springArmStep(current: Vec3, desired: Vec3, damping: number, dt: number): Vec3 — Exponential spring-arm approach toward a desired point (frame-rate independent).
206
- - resolveSideScroll (function): function resolveSideScroll(config: SideScrollCameraConfig | undefined): ResolvedSideScroll
207
- - sideScrollFollowBlend (function): function sideScrollFollowBlend(followSmoothing: number, dt: number): number — Frame-rate independent follow blend for the side-scroll rig; `followSmoothing <= 0` hard-locks (blend 1) instead of freezing.
208
- - resolveSideScrollPose (function): function resolveSideScrollPose(entityPos: Vec3, resolved: ResolvedSideScroll, fov: number): CameraPose — Fixed lateral 2.5D follow pose: the camera sits perpendicular to the travel axis at `distance`, above the entity by `height`, and looks at the entity raised by `lookHeight`. Axis "x" watches from +z; axis "z" watches from +x.
209
- - speedToFov (function): function speedToFov(speed: number, curve: { base?: number; max?: number; speedForMax?: number } | undefined): number — Speed→FOV curve: FOV climbs from base to max as speed rises to `speedForMax`.
210
- - yawTo (function): function yawTo(from: Vec3, to: Vec3): number — Yaw that points from `from` toward `to` on the XZ plane (matches shell forward = (sin, cos)).
211
- - angleDelta (function): function angleDelta(a: number, b: number): number — Shortest signed angular delta from `a` to `b`, wrapped to (-PI, PI].
212
- - smoothYaw (function): function smoothYaw(current: number, desired: number, speed: number, dt: number): number — Exponential yaw smoothing that respects wrap-around.
213
- - resolveShoulder (function): function resolveShoulder(config: ShoulderCameraConfig | undefined, aiming: boolean): ResolvedShoulder
214
- - blendShoulder (function): function blendShoulder(hip: ResolvedShoulder, ads: ResolvedShoulder, t: number): ResolvedShoulder — Blend two shoulder framings by an ADS factor in [0,1].
215
- - shoulderPose (function): function shoulderPose(follow: Vec3, yaw: number, pitch: number, sideSign: number, shoulder: ResolvedShoulder): CameraPose — Over-the-shoulder camera pose. The boom sits behind the follow point along `yaw`, raised by `heightOffset`, and pushed sideways by `shoulderOffset` (sign set by `sideSign`: +1 right, -1 left). `pitch` tilts the look point.
216
- - lockOnPose (function): function lockOnPose(player: Vec3, target: Vec3, config: LockOnCameraConfig | undefined, fov: number): { pose: CameraPose; yaw: number } — Lock-on pose: camera sits behind the player along the player→target vector so the target stays framed. The look point is biased between player and target by `framingBias`.
217
- - resolveChase (function): function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase
218
- - resolveObserver (function): function resolveObserver(config: ObserverCameraConfig | undefined): ResolvedObserver
219
- - observerPose (function): function observerPose(subject: Vec3, angle: number, resolved: ResolvedObserver, fov: number): CameraPose — Detached spectator pose: orbits `subject` at a fixed distance/height, never reading player input.
220
- - chaseDesiredPosition (function): function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
221
- - chaseLookAt (function): function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Look point ahead of / above a chased vehicle.
222
- - seatPose (function): function seatPose(follow: Vec3, yaw: number, local: { x?: number; y?: number; z?: number }, fov: number): CameraPose — World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
223
- - createTrauma (function): function createTrauma(): TraumaState
224
- - addTrauma (function): function addTrauma(state: TraumaState, amount: number): void — Add trauma (0..1) and clamp; larger hits raise the ceiling toward 1.
225
- - stepTrauma (function): function stepTrauma(state: TraumaState, decayPerSecond: number, dt: number): void — Decay trauma linearly toward 0 and advance the shake clock.
226
- - shakeOffset (function): function shakeOffset(state: TraumaState, config: { maxOffset?: number; maxRoll?: number; exponent?: number; frequency?: number; } | undefined): ShakeOffset — Positional + roll shake for the current trauma. Shake magnitude is `trauma^exponent` so small hits stay subtle; the oscillation is deterministic per-seed pseudo-noise (no per-frame RNG, so identical inputs reproduce).
227
- - crossfadePose (function): function crossfadePose(from: CameraPose, to: CameraPose, t: number): CameraPose — Linear cross-fade between two full camera poses (position, lookAt, fov).
228
- - resolveDirectedCamera (function): function resolveDirectedCamera(director: DirectorCameraValues | undefined, staticConfig: StaticCameraValues): ResolvedDirectedCamera — Merges a `CameraDirector` runtime snapshot over the static `GameCameraConfig` (#196.2). `director` omitted, or its fields `undefined`/`null`, is a pure passthrough to `staticConfig` so mounting a director with no active override changes nothing.
229
- - cinematicSample (function): function cinematicSample(keyframes: readonly CameraKeyframe[], elapsed: number, loop: boolean, fallbackFov: number): CinematicSample — Sample a keyframe path at `elapsed` seconds. Each keyframe's `duration` is the travel time from the previous keyframe into it; segment easing is per the destination keyframe. Reports `done` once past the final keyframe (unless looping, which wraps `elapsed` into the total path duration).
230
284
  - CameraPose (interface): interface CameraPose
231
- - ResolvedTopDown (interface): interface ResolvedTopDown
232
- - ResolvedSideScroll (interface): interface ResolvedSideScroll
233
- - ResolvedShoulder (interface): interface ResolvedShoulder
285
+ - CinematicSample (interface): interface CinematicSample
286
+ - DirectorCameraValues (interface): interface DirectorCameraValues
234
287
  - ResolvedChase (interface): interface ResolvedChase
288
+ - ResolvedDirectedCamera (interface): interface ResolvedDirectedCamera
235
289
  - ResolvedObserver (interface): interface ResolvedObserver
236
- - TraumaState (interface): interface TraumaState
290
+ - ResolvedShoulder (interface): interface ResolvedShoulder
291
+ - ResolvedSideScroll (interface): interface ResolvedSideScroll
292
+ - ResolvedTopDown (interface): interface ResolvedTopDown
237
293
  - ShakeOffset (interface): interface ShakeOffset
238
- - DirectorCameraValues (interface): interface DirectorCameraValues
239
294
  - StaticCameraValues (interface): interface StaticCameraValues
240
- - ResolvedDirectedCamera (interface): interface ResolvedDirectedCamera
241
- - CinematicSample (interface): interface CinematicSample
295
+ - TraumaState (interface): interface TraumaState
296
+ - addTrauma (function): function addTrauma(state: TraumaState, amount: number): void — Add trauma (0..1) and clamp; larger hits raise the ceiling toward 1.
297
+ - angleDelta (function): function angleDelta(a: number, b: number): number — Shortest signed angular delta from `a` to `b`, wrapped to (-PI, PI].
298
+ - bankRollStep (function): function bankRollStep(currentRoll: number, yaw: number, previousYaw: number, dt: number, resolved: ResolvedChase): number — One smoothing step of the chase rig's turn-bank roll (#286.10): roll opposes yaw rate, clamped and exponentially damped.
299
+ - blendShoulder (function): function blendShoulder(hip: ResolvedShoulder, ads: ResolvedShoulder, t: number): ResolvedShoulder — Blend two shoulder framings by an ADS factor in [0,1].
300
+ - chaseDesiredPosition (function): function chaseDesiredPosition(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Desired chase-camera position behind a vehicle facing `yaw` (before spring smoothing).
301
+ - chaseLookAt (function): function chaseLookAt(follow: Vec3, yaw: number, resolved: ResolvedChase): Vec3 — Look point ahead of / above a chased vehicle.
302
+ - cinematicSample (function): function cinematicSample(keyframes: readonly CameraKeyframe[], elapsed: number, loop: boolean, fallbackFov: number): CinematicSample — Sample a keyframe path at `elapsed` seconds. Each keyframe's `duration` is the travel time from the previous keyframe into it; segment easing is per the destination keyframe. Reports `done` once past the final keyframe (unless looping, which wraps `elapsed` into the total path duration).
303
+ - clamp (function): function clamp(value: number, min: number, max: number): number
304
+ - createTrauma (function): function createTrauma(): TraumaState
305
+ - crossfadePose (function): function crossfadePose(from: CameraPose, to: CameraPose, t: number): CameraPose — Linear cross-fade between two full camera poses (position, lookAt, fov).
306
+ - forwardVector (function): function forwardVector(yaw: number): Vec3
307
+ - leadFollowPoint (function): function leadFollowPoint(follow: Vec3, previous: Vec3, dt: number, resolved: ResolvedChase): Vec3 — Velocity-lead the follow point (#286.9): aim `leadTime` seconds along the target's frame velocity, clamped to `leadMax`.
308
+ - lerp (function): function lerp(from: number, to: number, blend: number): number
309
+ - lockOnPose (function): function lockOnPose(player: Vec3, target: Vec3, config: LockOnCameraConfig | undefined, fov: number): { pose: CameraPose; yaw: number } — Lock-on pose: camera sits behind the player along the player→target vector so the target stays framed. The look point is biased between player and target by `framingBias`.
310
+ - observerPose (function): function observerPose(subject: Vec3, angle: number, resolved: ResolvedObserver, fov: number): CameraPose — Detached spectator pose: orbits `subject` at a fixed distance/height, never reading player input.
311
+ - resolveChase (function): function resolveChase(config: ChaseCameraConfig | undefined): ResolvedChase
312
+ - resolveDirectedCamera (function): function resolveDirectedCamera(director: DirectorCameraValues | undefined, staticConfig: StaticCameraValues): ResolvedDirectedCamera — Merges a `CameraDirector` runtime snapshot over the static `GameCameraConfig` (#196.2). `director` omitted, or its fields `undefined`/`null`, is a pure passthrough to `staticConfig` so mounting a director with no active override changes nothing.
313
+ - resolveObserver (function): function resolveObserver(config: ObserverCameraConfig | undefined): ResolvedObserver
314
+ - resolveShoulder (function): function resolveShoulder(config: ShoulderCameraConfig | undefined, aiming: boolean): ResolvedShoulder
315
+ - resolveSideScroll (function): function resolveSideScroll(config: SideScrollCameraConfig | undefined): ResolvedSideScroll
316
+ - resolveSideScrollPose (function): function resolveSideScrollPose(entityPos: Vec3, resolved: ResolvedSideScroll, fov: number): CameraPose — Fixed lateral 2.5D follow pose: the camera sits perpendicular to the travel axis at `distance`, above the entity by `height`, and looks at the entity raised by `lookHeight`. Axis "x" watches from +z; axis "z" watches from +x.
317
+ - resolveTopDown (function): function resolveTopDown(config: TopDownCameraConfig | undefined): ResolvedTopDown
318
+ - rightVector (function): function rightVector(yaw: number): Vec3 — Screen-right of a yaw: `forward × up` with up = +Y.
319
+ - rtsPanKeysConflict (function): function rtsPanKeysConflict(input: ActionCodesMap | undefined): boolean
320
+ - seatPose (function): function seatPose(follow: Vec3, yaw: number, local: { x?: number; y?: number; z?: number }, fov: number): CameraPose — World-space seat pose (cockpit/hood/rear) rigidly attached to the vehicle.
321
+ - shakeOffset (function): function shakeOffset(state: TraumaState, config: { maxOffset?: number; maxRoll?: number; exponent?: number; frequency?: number; } | undefined): ShakeOffset — Positional + roll shake for the current trauma. Shake magnitude is `trauma^exponent` so small hits stay subtle; the oscillation is deterministic per-seed pseudo-noise (no per-frame RNG, so identical inputs reproduce).
322
+ - shoulderPose (function): function shoulderPose(follow: Vec3, yaw: number, pitch: number, sideSign: number, shoulder: ResolvedShoulder): CameraPose — Over-the-shoulder camera pose. The boom sits behind the follow point along `yaw`, raised by `heightOffset`, and pushed sideways by `shoulderOffset` (sign set by `sideSign`: +1 right, -1 left). `pitch` tilts the look point.
323
+ - sideScrollFollowBlend (function): function sideScrollFollowBlend(followSmoothing: number, dt: number): number — Frame-rate independent follow blend for the side-scroll rig; `followSmoothing <= 0` hard-locks (blend 1) instead of freezing.
324
+ - smoothYaw (function): function smoothYaw(current: number, desired: number, speed: number, dt: number): number — Exponential yaw smoothing that respects wrap-around.
325
+ - smoothstep (function): function smoothstep(t: number): number
326
+ - speedToFov (function): function speedToFov(speed: number, curve: { base?: number; max?: number; speedForMax?: number } | undefined): number — Speed→FOV curve: FOV climbs from base to max as speed rises to `speedForMax`.
327
+ - springArmStep (function): function springArmStep(current: Vec3, desired: Vec3, damping: number, dt: number): Vec3 — Exponential spring-arm approach toward a desired point (frame-rate independent).
328
+ - stepTrauma (function): function stepTrauma(state: TraumaState, decayPerSecond: number, dt: number): void — Decay trauma linearly toward 0 and advance the shake clock.
329
+ - topDownPose (function): function topDownPose(follow: Vec3, resolved: ResolvedTopDown, fov: number): CameraPose — Camera pose for a fixed top-down / isometric rig. `pitch` is the elevation of the camera→target ray above the ground (PI/2 = straight down); `yaw` is the fixed azimuth (PI/4 reads isometric). Height sets zoom; horizontal boom is derived so the look angle stays constant as height changes.
330
+ - yawTo (function): function yawTo(from: Vec3, to: Vec3): number — Yaw that points from `from` toward `to` on the XZ plane (matches shell forward = (sin, cos)).
242
331
 
243
332
  ### @jgengine/shell/camera/rigResolve
244
333
 
245
- - resolveRigKind (function): function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least specific: an explicit `rig` field always wins; then `perspective: "first"` (the historical shorthand for `rig: "orbit" | "first"`); then the mere presence of a rig's own config block selects that rig, checked in the fixed order below (#207.8) so a config carrying more than one block resolves deterministically instead of depending on object key order. Set `rig` explicitly to break a tie.
246
- - turntableAsObserver (function): function turntableAsObserver(config: GameCameraConfig | undefined): GameCameraConfig The turntable rig is a flat facade over the observer's point-orbit mode: map its `target`/`distance`/… onto an observer block so ObserverRig runs unchanged.
334
+ - resolveRigKind (function): function resolveRigKind(config: GameCameraConfig | undefined): CameraRigKind — Resolves which rig mounts from a `GameCameraConfig`. Precedence, most to least specific: an explicit `rig` field always wins; then `perspective: "first"` (the historical shorthand for `rig: "orbit" | "first"`); then the mere presence of a rig's own config block selects that rig, checked in the fixed order below (#207.8) so a config carrying more than one block resolves deterministically instead of depending on object key order. Set `rig` explicitly to break a tie.
335
+ - turntableAsObserver (function): function turntableAsObserver(config: GameCameraConfig | undefined): GameCameraConfig — The turntable rig is a flat facade over the observer's point-orbit mode: map its `target`/`distance`/… onto an observer block so ObserverRig runs unchanged.
247
336
 
248
337
  ### @jgengine/shell/camera/shakeChannel
249
338
 
339
+ - CameraShakeChannel (interface): interface CameraShakeChannel
340
+ - CameraShakeContext (const): const CameraShakeContext: React.Context<CameraShakeChannel>
341
+ - cameraShake (function): function cameraShake(amplitude: number, decayPerSecond?: number): void — Feed the default camera-shake channel from anywhere (see G7 hitstop cross-cut).
250
342
  - createCameraShakeChannel (function): function createCameraShakeChannel(defaultDecayPerSecond = 1.6): CameraShakeChannel
251
- - cameraShake (function): function cameraShake(amplitude: number, decayPerSecond?: number): void Feed the default camera-shake channel from anywhere (see G7 hitstop cross-cut).
252
- - useCameraShake (function): function useCameraShake(): CameraShakeChannel The active rig's shake channel — call `.shake(...)` to add trauma from React UI.
343
+ - defaultCameraShakeChannel (const): const defaultCameraShakeChannel: CameraShakeChannel — Process-wide default channel. A shell mounts its own channel via `CameraShakeContext`, but game systems that have no React context (e.g. a `loop.onTick` reacting to `entity.died`) can import `cameraShake` and feed the default channel directly.
344
+ - useCameraShake (function): function useCameraShake(): CameraShakeChannel — The active rig's shake channel — call `.shake(...)` to add trauma from React UI.
345
+
346
+ ### @jgengine/shell/camera/shakeChannelMath
347
+
253
348
  - CameraShakeChannel (interface): interface CameraShakeChannel
254
- - defaultCameraShakeChannel (const): const defaultCameraShakeChannel: CameraShakeChannel — Process-wide default channel. A shell mounts its own channel via `CameraShakeContext`, but game systems that have no React context (e.g. a `loop.onTick` reacting to `entity.died`) can import `cameraShake` and feed the default channel directly.
255
- - CameraShakeContext (const): const CameraShakeContext: React.Context<import("/home/runner/work/jgengine/jgengine/packages/shell/src/camera/shakeChannel").CameraShakeChannel>
349
+ - createCameraShakeChannel (function): function createCameraShakeChannel(defaultDecayPerSecond = 1.6): CameraShakeChannel
350
+
351
+ ### @jgengine/shell/cartridge
352
+
353
+ - CartridgeAbilitySlot (interface): interface CartridgeAbilitySlot
354
+ - CartridgeConfig (type): type CartridgeConfig = CartridgeSpec & { name: string; panels: CartridgePanels; hud: { storageKey?: string; panels: readonly CartridgeHudPanelSpec[] }; screens: CartridgeScreens; theme?: Record<string, string>; world?: GameConfig["world"]; physics?: GameConfig["physics"]; assets?: GameConfig["assets…
355
+ - CartridgeHudPanelSpec (interface): interface CartridgeHudPanelSpec
356
+ - CartridgePanelItem (type): type CartridgePanelItem = | { kind: "vital"; stat: string; label: string; tone?: string; width?: number } | { kind: "xp"; width?: number } | { kind: "timer"; label: string } | { kind: "score"; source: "kills"; label: string; digits?: number } | { kind: "abilityBar"; icons: Record<string, string> } |…
357
+ - CartridgePanels (interface): interface CartridgePanels
358
+ - CartridgeResultLine (type): type CartridgeResultLine = { label: string; accent?: boolean } & ( | { source: "kills" | "level" } | { value: string | number } )
359
+ - CartridgeScreens (interface): interface CartridgeScreens
360
+ - cartridge (function): function cartridge(config: CartridgeConfig): PlayableGame
256
361
 
257
362
  ### @jgengine/shell/defineGame
258
363
 
259
- - defineGame (function): function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(config: GameConfig<TAssetRef>): PlayableGame
260
364
  - GameConfig (type): type GameConfig<TAssetRef extends ModelAssetRef = ModelAssetRef> = EngineFields<TAssetRef> & PresentationFields
365
+ - defineGame (function): function defineGame<TAssetRef extends ModelAssetRef = ModelAssetRef>(config: GameConfig<TAssetRef>): PlayableGame
366
+
367
+ ### @jgengine/shell/devtools/CollisionDebugWorld
368
+
369
+ - CollisionDebugWorld (function): function CollisionDebugWorld(): React.JSX.Element | null — World-space collision debugger. Performs zero scene scans and zero raycasts when every layer is off. Mount only when shell devtools are enabled.
261
370
 
262
371
  ### @jgengine/shell/devtools/DevtoolsOverlay
263
372
 
264
- - persistDevtoolsOverrides (function): function persistDevtoolsOverrides(gameName: string): DevtoolsOverrides
373
+ - DevtoolsOverlay (function): function DevtoolsOverlay({ open, ctx, playable, multiplayer, }: { open: boolean; ctx: GameContext; playable: PlayableGame; multiplayer: ShellMultiplayer | null; }): React.JSX.Element | null
374
+ - DevtoolsRendererProbe (function): function DevtoolsRendererProbe(): null
265
375
  - applyStoredDevtoolsOverrides (function): function applyStoredDevtoolsOverrides(gameName: string): void
376
+ - buildFullReport (function): function buildFullReport(playable: PlayableGame): DevtoolsSnapshot & { game: string }
377
+ - buildLeanReport (function): function buildLeanReport(playable: PlayableGame): { game: any; at: any; why: string | null; frame: { fps: number; avgFrameMs: number; p95FrameMs: number; maxFrameMs: number; avgSimMs: number; maxSimMs: number; avgOutsideMs: number; maxOutsideMs: number; longFrames: any; samples: any; phases: any; } …
378
+ - persistDevtoolsOverrides (function): function persistDevtoolsOverrides(gameName: string): DevtoolsOverrides
266
379
  - withDevtoolsLatency (function): function withDevtoolsLatency(multiplayer: ShellMultiplayer): ShellMultiplayer
267
- - DevtoolsRendererProbe (function): function DevtoolsRendererProbe(): null
268
- - DevtoolsOverlay (function): function DevtoolsOverlay({ open, ctx, playable, multiplayer, }: { open: boolean; ctx: GameContext; playable: PlayableGame; multiplayer: ShellMultiplayer | null; }): React.JSX.Element | null
380
+
381
+ ### @jgengine/shell/devtools/collisionDebug
382
+
383
+ - AimProbeConfig (interface): interface AimProbeConfig
384
+ - COLLISION_DEBUG_LAYERS (const): const COLLISION_DEBUG_LAYERS: readonly CollisionDebugLayer[]
385
+ - CollisionDebugController (interface): interface CollisionDebugController
386
+ - CollisionDebugLayer (type): type CollisionDebugLayer = | "hitboxes" | "bodies" | "projectiles" | "muzzles" | "aimLaser"
387
+ - CollisionDebugLayers (type): type CollisionDebugLayers = Record<CollisionDebugLayer, boolean>
388
+ - CollisionDebugListener (type): type CollisionDebugListener = () => void
389
+ - CollisionDebugState (interface): interface CollisionDebugState
390
+ - ProjectileDebugTrace (interface): interface ProjectileDebugTrace
391
+ - aimProbeNeeded (function): function aimProbeNeeded(layers: CollisionDebugLayers): boolean
392
+ - anyCollisionLayerOn (function): function anyCollisionLayerOn(layers: CollisionDebugLayers): boolean
393
+ - colliderScanNeeded (function): function colliderScanNeeded(layers: CollisionDebugLayers): boolean
394
+ - collisionDebug (const): const collisionDebug: CollisionDebugController
395
+ - createCollisionDebugController (function): function createCollisionDebugController(initial: CollisionDebugState = createDefaultCollisionDebugState()): CollisionDebugController
396
+ - createDefaultCollisionDebugState (function): function createDefaultCollisionDebugState(): CollisionDebugState
397
+ - projectileListenNeeded (function): function projectileListenNeeded(layers: CollisionDebugLayers): boolean
398
+
399
+ ### @jgengine/shell/devtools/collisionDebugMath
400
+
401
+ - AIM_DAMAGE_COLOR (const): const AIM_DAMAGE_COLOR: "#f87171"
402
+ - AIM_LASER_COLOR (const): const AIM_LASER_COLOR: "#a3e635"
403
+ - AIM_MISS_COLOR (const): const AIM_MISS_COLOR: "#94a3b8"
404
+ - AIM_SOLID_COLOR (const): const AIM_SOLID_COLOR: "#fbbf24"
405
+ - AimEndpointKind (type): type AimEndpointKind = "damage" | "solid" | "miss"
406
+ - AimLaserDebug (interface): interface AimLaserDebug
407
+ - BODY_WIRE_COLOR (const): const BODY_WIRE_COLOR: "#38bdf8"
408
+ - CollectDebugShapesInput (interface): interface CollectDebugShapesInput
409
+ - ComputeAimLaserInput (interface): interface ComputeAimLaserInput
410
+ - DebugShapeEntry (interface): interface DebugShapeEntry
411
+ - HITBOX_WIRE_COLOR (const): const HITBOX_WIRE_COLOR: "#f472b6"
412
+ - PROJECTILE_PATH_COLOR (const): const PROJECTILE_PATH_COLOR: "#fde68a"
413
+ - classifyAimEndpoint (function): function classifyAimEndpoint(hit: Pick<SceneRaycastHit, "damageEligible"> | null | undefined): AimEndpointKind
414
+ - collectDebugShapes (function): function collectDebugShapes(input: CollectDebugShapesInput): DebugShapeEntry[] — Collects world-space collider wireframe entries for enabled layers. Returns [] and performs no entity/object iteration when both hitbox/body layers are off.
415
+ - computeAimLaser (function): function computeAimLaser(input: ComputeAimLaserInput): AimLaserDebug | null — Builds the authoritative aim-laser segment using resolveShot + scene raycast (same seam as projectile prediction/settlement). Zero queries when aimLaser is off.
416
+ - muzzleMarkerFromOrigin (function): function muzzleMarkerFromOrigin(origin: EntityPosition): { center: EntityPosition; radius: number; color: string; }
417
+ - pointAlongRay (function): function pointAlongRay(origin: EntityPosition, direction: EntityPosition, distance: number): EntityPosition
418
+ - shapeWorldCenter (function): function shapeWorldCenter(collider: ResolvedCollider, position: EntityPosition, rotationY: number): EntityPosition
269
419
 
270
420
  ### @jgengine/shell/environment
271
421
 
272
- - Daylight (function): function Daylight({ sky, fog, sun, ambient }: DaylightProps = {}): React.JSX.Element
273
- - SkyDaylight (function): function SkyDaylight({ sky }: { sky: SkyEnvironmentDescriptor }): React.JSX.Element — Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
274
- - SkyDome (function): function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, materialRef, }: SkyDomeProps = {}): React.JSX.Element
275
- - TimeOfDayDaylight (function): function TimeOfDayDaylight({ sky, clock }: TimeOfDayDaylightProps): React.JSX.Element — Drives `Daylight`'s sun/sky/fog from the world clock when `sky.timeOfDay` and `clock` are both present, sampling `daylightStateAt` every frame; otherwise renders the static preset look via `SkyDaylight`.
422
+ - Daylight (function): function Daylight({ sky, fog, sun, ambient, lights = true }: DaylightProps = {}): React.JSX.Element
423
+ - DaylightCycleConfig (interface): interface DaylightCycleConfig
276
424
  - DaylightProps (interface): interface DaylightProps
277
- - SkyDomeProps (interface): interface SkyDomeProps
278
- - TimeOfDayDaylightProps (interface): interface TimeOfDayDaylightProps
425
+ - DaylightState (interface): interface DaylightState
279
426
  - EnvironmentScene (function): function EnvironmentScene({ feature }: EnvironmentSceneProps): React.JSX.Element
280
427
  - EnvironmentSceneProps (interface): interface EnvironmentSceneProps
281
- - daylightStateAt (function): function daylightStateAt(dayFraction: number, config: DaylightCycleConfig = {}): DaylightState — Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk), lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
282
- - lerpHexColor (function): function lerpHexColor(a: string, b: string, t: number): string
283
428
  - SKY_PRESET_DAY_FRACTION (const): const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number>
284
- - DaylightCycleConfig (interface): interface DaylightCycleConfig
285
- - DaylightState (interface): interface DaylightState
429
+ - SkyDaylight (function): function SkyDaylight({ sky, lights = true }: SkyDaylightProps): React.JSX.Element — Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
430
+ - SkyDaylightProps (interface): interface SkyDaylightProps
431
+ - SkyDome (function): function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, materialRef, }: SkyDomeProps = {}): React.JSX.Element
432
+ - SkyDomeProps (interface): interface SkyDomeProps
433
+ - SkyLightOwnership (type): type SkyLightOwnership = "authored" | "sky-default" — Policy for composing sky backdrops with `PlayableGame.lighting`: - authored lighting present → sky renders dome + fog only; lights stay game-owned - no authored lighting → sky may emit its default sun/hemisphere with the dome Time-of-day never rewrites configured lights; it only drives sky colors/fog (and sky-owned lights when the game did not author lighting).
434
+ - TimeOfDayDaylight (function): function TimeOfDayDaylight({ sky, clock, lights = true }: TimeOfDayDaylightProps): React.JSX.Element — Drives sky/fog (and optional default lights) from the world clock when `sky.timeOfDay` and `clock` are both present. Authored `PlayableGame.lighting` is never rewritten — pass `lights={false}` so only dome colors and fog track the day fraction.
435
+ - TimeOfDayDaylightProps (interface): interface TimeOfDayDaylightProps
436
+ - daylightStateAt (function): function daylightStateAt(dayFraction: number, config: DaylightCycleConfig = {}): DaylightState — Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk), lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
437
+ - lerpHexColor (function): function lerpHexColor(a: string, b: string, t: number): string
438
+ - resolveSkyLightOwnership (function): function resolveSkyLightOwnership(hasAuthoredLighting: boolean): SkyLightOwnership
439
+ - skyEmitsLights (function): function skyEmitsLights(ownership: SkyLightOwnership): boolean
286
440
 
287
441
  ### @jgengine/shell/environment/Daylight
288
442
 
443
+ - Daylight (function): function Daylight({ sky, fog, sun, ambient, lights = true }: DaylightProps = {}): React.JSX.Element
444
+ - DaylightProps (interface): interface DaylightProps
445
+ - SkyDaylight (function): function SkyDaylight({ sky, lights = true }: SkyDaylightProps): React.JSX.Element — Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
446
+ - SkyDaylightProps (interface): interface SkyDaylightProps
289
447
  - SkyDome (function): function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, materialRef, }: SkyDomeProps = {}): React.JSX.Element
290
- - Daylight (function): function Daylight({ sky, fog, sun, ambient }: DaylightProps = {}): React.JSX.Element
291
- - SkyDaylight (function): function SkyDaylight({ sky }: { sky: SkyEnvironmentDescriptor }): React.JSX.Element — Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
292
- - TimeOfDayDaylight (function): function TimeOfDayDaylight({ sky, clock }: TimeOfDayDaylightProps): React.JSX.Element — Drives `Daylight`'s sun/sky/fog from the world clock when `sky.timeOfDay` and `clock` are both present, sampling `daylightStateAt` every frame; otherwise renders the static preset look via `SkyDaylight`.
293
448
  - SkyDomeProps (interface): interface SkyDomeProps
294
- - DaylightProps (interface): interface DaylightProps
449
+ - TimeOfDayDaylight (function): function TimeOfDayDaylight({ sky, clock, lights = true }: TimeOfDayDaylightProps): React.JSX.Element — Drives sky/fog (and optional default lights) from the world clock when `sky.timeOfDay` and `clock` are both present. Authored `PlayableGame.lighting` is never rewritten — pass `lights={false}` so only dome colors and fog track the day fraction.
295
450
  - TimeOfDayDaylightProps (interface): interface TimeOfDayDaylightProps
296
451
 
297
- ### @jgengine/shell/environment/daylightCycle
298
-
299
- - lerpHexColor (function): function lerpHexColor(a: string, b: string, t: number): string
300
- - daylightStateAt (function): function daylightStateAt(dayFraction: number, config: DaylightCycleConfig = {}): DaylightState — Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk), lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
301
- - DaylightCycleConfig (interface): interface DaylightCycleConfig
302
- - DaylightState (interface): interface DaylightState
303
- - DEFAULT_DAY_SUN_INTENSITY (const): const DEFAULT_DAY_SUN_INTENSITY: 1
304
- - DEFAULT_DAY_AMBIENT_INTENSITY (const): const DEFAULT_DAY_AMBIENT_INTENSITY: 0.6
305
- - DEFAULT_DAY_SKY_TOP (const): const DEFAULT_DAY_SKY_TOP: "#3fa4f2"
306
- - DEFAULT_DAY_SKY_BOTTOM (const): const DEFAULT_DAY_SKY_BOTTOM: "#e3f4ff"
307
- - SKY_PRESET_DAY_FRACTION (const): const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number>
308
-
309
452
  ### @jgengine/shell/environment/EnvironmentScene
310
453
 
311
454
  - EnvironmentScene (function): function EnvironmentScene({ feature }: EnvironmentSceneProps): React.JSX.Element
@@ -316,97 +459,99 @@ Imports use deep paths: `@jgengine/shell/<path>`.
316
459
  - GroundPad (function): function GroundPad({ pad, field }: GroundPadProps): React.JSX.Element
317
460
  - GroundPadProps (interface): interface GroundPadProps
318
461
 
462
+ ### @jgengine/shell/environment/daylightCycle
463
+
464
+ - DEFAULT_DAY_AMBIENT_INTENSITY (const): const DEFAULT_DAY_AMBIENT_INTENSITY: 0.6
465
+ - DEFAULT_DAY_SKY_BOTTOM (const): const DEFAULT_DAY_SKY_BOTTOM: "#e3f4ff"
466
+ - DEFAULT_DAY_SKY_TOP (const): const DEFAULT_DAY_SKY_TOP: "#3fa4f2"
467
+ - DEFAULT_DAY_SUN_INTENSITY (const): const DEFAULT_DAY_SUN_INTENSITY: 1
468
+ - DaylightCycleConfig (interface): interface DaylightCycleConfig
469
+ - DaylightState (interface): interface DaylightState
470
+ - SKY_PRESET_DAY_FRACTION (const): const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number>
471
+ - daylightStateAt (function): function daylightStateAt(dayFraction: number, config: DaylightCycleConfig = {}): DaylightState — Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk), lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
472
+ - lerpHexColor (function): function lerpHexColor(a: string, b: string, t: number): string
473
+
319
474
  ### @jgengine/shell/environment/groundPadMath
320
475
 
321
- - resolvePadShape (function): function resolvePadShape(size: PadSize): PadShape
322
- - resolvePadSurfaceY (function): function resolvePadSurfaceY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height">): number
323
- - resolvePadMeshY (function): function resolvePadMeshY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height">): number
324
476
  - PAD_THICKNESS (const): const PAD_THICKNESS: 0.1
325
477
  - PadShape (type): type PadShape = { circular: true; radius: number } | { circular: false; width: number; depth: number }
478
+ - resolvePadMeshY (function): function resolvePadMeshY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height" | "elevation">): number
479
+ - resolvePadShape (function): function resolvePadShape(size: PadSize): PadShape
480
+ - resolvePadSurfaceY (function): function resolvePadSurfaceY(groundHeight: number, pad: Pick<PadEnvironmentDescriptor, "height" | "elevation">): number
326
481
 
327
482
  ### @jgengine/shell/environment/index
328
483
 
329
- - Daylight (function): function Daylight({ sky, fog, sun, ambient }: DaylightProps = {}): React.JSX.Element
330
- - SkyDaylight (function): function SkyDaylight({ sky }: { sky: SkyEnvironmentDescriptor }): React.JSX.Element — Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
331
- - SkyDome (function): function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, materialRef, }: SkyDomeProps = {}): React.JSX.Element
332
- - TimeOfDayDaylight (function): function TimeOfDayDaylight({ sky, clock }: TimeOfDayDaylightProps): React.JSX.Element — Drives `Daylight`'s sun/sky/fog from the world clock when `sky.timeOfDay` and `clock` are both present, sampling `daylightStateAt` every frame; otherwise renders the static preset look via `SkyDaylight`.
484
+ - Daylight (function): function Daylight({ sky, fog, sun, ambient, lights = true }: DaylightProps = {}): React.JSX.Element
485
+ - DaylightCycleConfig (interface): interface DaylightCycleConfig
333
486
  - DaylightProps (interface): interface DaylightProps
334
- - SkyDomeProps (interface): interface SkyDomeProps
335
- - TimeOfDayDaylightProps (interface): interface TimeOfDayDaylightProps
487
+ - DaylightState (interface): interface DaylightState
336
488
  - EnvironmentScene (function): function EnvironmentScene({ feature }: EnvironmentSceneProps): React.JSX.Element
337
489
  - EnvironmentSceneProps (interface): interface EnvironmentSceneProps
338
- - daylightStateAt (function): function daylightStateAt(dayFraction: number, config: DaylightCycleConfig = {}): DaylightState — Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk), lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
339
- - lerpHexColor (function): function lerpHexColor(a: string, b: string, t: number): string
340
490
  - SKY_PRESET_DAY_FRACTION (const): const SKY_PRESET_DAY_FRACTION: Record<"day" | "dusk" | "night", number>
341
- - DaylightCycleConfig (interface): interface DaylightCycleConfig
342
- - DaylightState (interface): interface DaylightState
343
-
344
- ### @jgengine/shell/GameHost
345
-
346
- - GameHost (function): function GameHost({ playable, gameId, wsUrl, multiplayer, resolveMultiplayer }: GameHostProps): React.JSX.Element
347
- - GameHostProps (interface): interface GameHostProps
491
+ - SkyDaylight (function): function SkyDaylight({ sky, lights = true }: SkyDaylightProps): React.JSX.Element — Renders a fixed sky/sun/fog look sampled from `sky`'s preset (or, when `timeOfDay` is on but no clock drives it, its noon look). No per-frame updates.
492
+ - SkyDaylightProps (interface): interface SkyDaylightProps
493
+ - SkyDome (function): function SkyDome({ topColor = SKY_TOP, horizonColor = SKY_HORIZON, radius = 260, offset = 24, exponent = 0.65, materialRef, }: SkyDomeProps = {}): React.JSX.Element
494
+ - SkyDomeProps (interface): interface SkyDomeProps
495
+ - SkyLightOwnership (type): type SkyLightOwnership = "authored" | "sky-default" — Policy for composing sky backdrops with `PlayableGame.lighting`: - authored lighting present → sky renders dome + fog only; lights stay game-owned - no authored lighting → sky may emit its default sun/hemisphere with the dome Time-of-day never rewrites configured lights; it only drives sky colors/fog (and sky-owned lights when the game did not author lighting).
496
+ - TimeOfDayDaylight (function): function TimeOfDayDaylight({ sky, clock, lights = true }: TimeOfDayDaylightProps): React.JSX.Element — Drives sky/fog (and optional default lights) from the world clock when `sky.timeOfDay` and `clock` are both present. Authored `PlayableGame.lighting` is never rewritten — pass `lights={false}` so only dome colors and fog track the day fraction.
497
+ - TimeOfDayDaylightProps (interface): interface TimeOfDayDaylightProps
498
+ - daylightStateAt (function): function daylightStateAt(dayFraction: number, config: DaylightCycleConfig = {}): DaylightState — Samples the daylight cycle at a point in the day (0 = midnight, 0.25 = dawn, 0.5 = noon, 0.75 = dusk), lerping sun position/intensity, ambient intensity, and sky colors through a dawn/day/dusk/night keyframe table. `config` overrides the noon (peak-day) colors and intensities only.
499
+ - lerpHexColor (function): function lerpHexColor(a: string, b: string, t: number): string
500
+ - resolveSkyLightOwnership (function): function resolveSkyLightOwnership(hasAuthoredLighting: boolean): SkyLightOwnership
501
+ - skyEmitsLights (function): function skyEmitsLights(ownership: SkyLightOwnership): boolean
348
502
 
349
- ### @jgengine/shell/GamePlayer
503
+ ### @jgengine/shell/environment/skyLightingPolicy
350
504
 
351
- - GamePlayer (function): function GamePlayer({ gameId, registry, fallbackGameId, loading = null, multiplayer = null }: GamePlayerProps): React.JSX.Element
352
- - GamePlayerProps (type): type GamePlayerProps = { gameId: string; registry: GameRegistry; fallbackGameId?: string; loading?: ReactNode; multiplayer?: ShellMultiplayer | null; }
505
+ - SkyLightOwnership (type): type SkyLightOwnership = "authored" | "sky-default" — Policy for composing sky backdrops with `PlayableGame.lighting`: - authored lighting present → sky renders dome + fog only; lights stay game-owned - no authored lighting → sky may emit its default sun/hemisphere with the dome Time-of-day never rewrites configured lights; it only drives sky colors/fog (and sky-owned lights when the game did not author lighting).
506
+ - resolveSkyLightOwnership (function): function resolveSkyLightOwnership(hasAuthoredLighting: boolean): SkyLightOwnership
507
+ - skyEmitsLights (function): function skyEmitsLights(ownership: SkyLightOwnership): boolean
353
508
 
354
- ### @jgengine/shell/GamePlayerShell
509
+ ### @jgengine/shell/input/mouseLook
355
510
 
356
- - heldActionsFor (function): function heldActionsFor(tracker: Pick<ActionStateTracker<string>, "isDown">, actions: readonly string[]): string[] — Actions from `input` currently held down, for `ctx.input.publish` (#164.1); includes reserved movement/jump actions.
357
- - shouldFireBoundAction (function): function shouldFireBoundAction(tracker: Pick<ActionStateTracker<string>, "isDown" | "wasPressed">, action: string, input: PlayableGame["game"]["input"], repeatFiredAt: ReadonlyMap<string, number>, now: number): boolean — Whether a bound action should fire this frame: on press, or on repeat interval while held (shared by `FrameDriver` and `HudOnlyDriver`).
358
- - dispatchBoundAction (function): function dispatchBoundAction(ctx: GameContext, action: string, yaw: number, pitch: number, aim: Aim): void Resolves and runs the command bound to `action` via the shell's action→command convention (shared by `FrameDriver` and `HudOnlyDriver`).
359
- - nearbyObstacles (function): function nearbyObstacles(objects: readonly SceneObject[], center: readonly [number, number, number], radius: number = OBSTACLE_GATHER_RADIUS): CollisionObstacle[] — Placed scene objects within `radius` of `center`, as `CollisionObstacle`s for `resolveObstacleStep` (#162.1).
360
- - applyMotionImpulses (function): function applyMotionImpulses(currentVelocity: number, batch: MotionIntentBatch | null): number — Applies a pending `MotionIntentBatch` to a vertical velocity: impulses add, then `verticalVelocity` replaces the result outright (#162.4).
361
- - resolveWorldSky (function): function resolveWorldSky(world: WorldFeature | undefined): SkyEnvironmentDescriptor | undefined — The world's declared sky, when its world feature is an environment with one (#196.1).
362
- - resolvePhysicsTuning (function): function resolvePhysicsTuning(physics: PhysicsConfig | undefined): MovementTuningOverrides | undefined — Maps the game's declared `physics` onto the movement controllers' tuning overrides. `PhysicsConfig.gravity` is a signed world acceleration (negative points down, matching every game's config and the Y-up convention), but the controllers integrate `velocityY -= gravityAcceleration * dt` and so expect a positive downward magnitude. Negating here is what keeps a down-pointing gravity pulling the player *down*; passing the signed value straight through flipped the sign and launched airborne players upward instead.
363
- - hasEnvironmentTerrain (function): function hasEnvironmentTerrain(world: WorldFeature | undefined): boolean — True when the world is an environment feature with terrain, so the voxel controller should sample its height.
364
- - GamePlayerShell (function): function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null, }: { playable: PlayableGame; multiplayer?: ShellMultiplayer | null; }): React.JSX.Element
511
+ - MouseLookAim (interface): interface MouseLookAim
512
+ - MouseLookOptions (interface): interface MouseLookOptions
513
+ - MouseLookTracker (interface): interface MouseLookTracker — The analog mouse-look service chase/orbit-cam games hand-rolled (#282.8) — pointer-lock lifecycle plus delta accumulation into a yaw/pitch aim, decoupled from the first-person rig. Attach it to the canvas, read `aim()` from `onTick`/`useFrame`, dispose on unmount.
514
+ - createMouseLookTracker (function): function createMouseLookTracker(element: HTMLElement, options: MouseLookOptions = {}): MouseLookTracker
365
515
 
366
- ### @jgengine/shell/GameUiPreview
516
+ ### @jgengine/shell/map/MapMarkerBeacons
367
517
 
368
- - GameUiPreview (function): function GameUiPreview({ playable, scenario = defaultUiScenario, }: { playable: PlayableGame; scenario?: UiPreviewScenario; }): React.JSX.Element
369
- - UiPreviewScenario (type): type UiPreviewScenario = (ctx: GameContext, playable: PlayableGame) => void
370
- - defaultUiScenario (const): const defaultUiScenario: UiPreviewScenario
518
+ - MapMarkerBeacons (function): function MapMarkerBeacons({ markers, kindStyles, height = 5 }: MapMarkerBeaconsProps): React.JSX.Element — World-space beacons for map markers (the visible in-world side of a ping): a floating diamond over a soft light beam, colored by marker kind. Wire it through `PlayableGame.WorldOverlay`.
519
+ - MapMarkerBeaconsProps (interface): interface MapMarkerBeaconsProps
371
520
 
372
521
  ### @jgengine/shell/map/index
373
522
 
374
- - bakeTerrainMap (function): function bakeTerrainMap(field: TerrainField, bounds: MapBakeBounds, options: BakeTerrainMapOptions = {}): BakedMap | null — Bake a top-down image of a `TerrainField` (or `RegionField`) over `bounds` for the react `Minimap` / `WorldMap` background. Runs in the browser via a 2D canvas; renderer-side, so it lives in the shell.
375
- - BakedMap (interface): interface BakedMap
376
523
  - BakeTerrainMapOptions (interface): interface BakeTerrainMapOptions
524
+ - BakedMap (interface): interface BakedMap
377
525
  - MapBakeBounds (interface): interface MapBakeBounds
378
- - MapMarkerBeacons (function): function MapMarkerBeacons({ markers, kindStyles, height = 5 }: MapMarkerBeaconsProps): React.JSX.Element World-space beacons for map markers (the visible in-world side of a ping): a floating diamond over a soft light beam, colored by marker kind. Wire it through `PlayableGame.WorldOverlay`.
379
- - MapMarkerBeaconsProps (interface): interface MapMarkerBeaconsProps
380
-
381
- ### @jgengine/shell/map/MapMarkerBeacons
382
-
383
- - MapMarkerBeacons (function): function MapMarkerBeacons({ markers, kindStyles, height = 5 }: MapMarkerBeaconsProps): React.JSX.Element — World-space beacons for map markers (the visible in-world side of a ping): a floating diamond over a soft light beam, colored by marker kind. Wire it through `PlayableGame.WorldOverlay`.
526
+ - MapMarkerBeacons (function): function MapMarkerBeacons({ markers, kindStyles, height = 5 }: MapMarkerBeaconsProps): React.JSX.Element — World-space beacons for map markers (the visible in-world side of a ping): a floating diamond over a soft light beam, colored by marker kind. Wire it through `PlayableGame.WorldOverlay`.
384
527
  - MapMarkerBeaconsProps (interface): interface MapMarkerBeaconsProps
528
+ - bakeTerrainMap (function): function bakeTerrainMap(field: TerrainField, bounds: MapBakeBounds, options: BakeTerrainMapOptions = {}): BakedMap | null — Bake a top-down image of a `TerrainField` (or `RegionField`) over `bounds` for the react `Minimap` / `WorldMap` background. Runs in the browser via a 2D canvas; renderer-side, so it lives in the shell.
385
529
 
386
530
  ### @jgengine/shell/map/terrainMap
387
531
 
388
- - bakeTerrainMap (function): function bakeTerrainMap(field: TerrainField, bounds: MapBakeBounds, options: BakeTerrainMapOptions = {}): BakedMap | null — Bake a top-down image of a `TerrainField` (or `RegionField`) over `bounds` for the react `Minimap` / `WorldMap` background. Runs in the browser via a 2D canvas; renderer-side, so it lives in the shell.
389
- - MapBakeBounds (interface): interface MapBakeBounds
390
532
  - BakeTerrainMapOptions (interface): interface BakeTerrainMapOptions
391
533
  - BakedMap (interface): interface BakedMap
534
+ - MapBakeBounds (interface): interface MapBakeBounds
535
+ - bakeTerrainMap (function): function bakeTerrainMap(field: TerrainField, bounds: MapBakeBounds, options: BakeTerrainMapOptions = {}): BakedMap | null — Bake a top-down image of a `TerrainField` (or `RegionField`) over `bounds` for the react `Minimap` / `WorldMap` background. Runs in the browser via a 2D canvas; renderer-side, so it lives in the shell.
392
536
 
393
537
  ### @jgengine/shell/materialOverride
394
538
 
395
- - applyMaterialOverride (function): function applyMaterialOverride(root: THREE.Object3D, override: ModelMaterialOverride): void — Clones each `MeshStandardMaterial` under `root` and applies `override`'s color/finish onto the clone, so shared GLTF-cache scenes are never mutated in place (#151.3).
539
+ - MaterialOverrideOptions (interface): interface MaterialOverrideOptions
540
+ - applyMaterialOverride (function): function applyMaterialOverride(root: THREE.Object3D, override: ModelMaterialOverride, options?: MaterialOverrideOptions): void
396
541
 
397
542
  ### @jgengine/shell/multiplayer
398
543
 
399
- - randomPlayerId (function): function randomPlayerId(): string
400
- - resolveShellMultiplayer (function): function resolveShellMultiplayer(args: ResolveShellMultiplayerArgs): ShellMultiplayer | null
401
- - resolvePeerShellMultiplayer (function): function resolvePeerShellMultiplayer(args: { gameId: string; role: "host" | "join"; room?: string; userId?: string; feedActions?: string[]; }): Promise<ShellMultiplayer & { close: () => void }>
402
- - ShellMultiplayer (type): type ShellMultiplayer = MultiplayerSession
403
544
  - DEFAULT_FEED_ACTIONS (const): const DEFAULT_FEED_ACTIONS: string[]
404
545
  - ResolveShellMultiplayerArgs (type): type ResolveShellMultiplayerArgs = { game: GameDefinition; gameId: string; url?: string; userId?: string; force?: boolean; feedActions?: string[]; }
546
+ - ShellMultiplayer (type): type ShellMultiplayer = MultiplayerSession
547
+ - randomPlayerId (function): function randomPlayerId(): string
548
+ - resolvePeerShellMultiplayer (function): function resolvePeerShellMultiplayer(args: { gameId: string; role: "host" | "join"; room?: string; userId?: string; feedActions?: string[]; }): Promise<ShellMultiplayer & { close: () => void }>
549
+ - resolveShellMultiplayer (function): function resolveShellMultiplayer(args: ResolveShellMultiplayerArgs): ShellMultiplayer | null
405
550
 
406
551
  ### @jgengine/shell/pointer/PointerOverlays
407
552
 
408
- - MarqueeBox (function): function MarqueeBox({ rect }: { rect: ScreenRect }): React.JSX.Element
409
553
  - ContextMenuView (function): function ContextMenuView({ menu, x, y, onPick, onClose, }: { menu: ContextMenu; x: number; y: number; onPick: (verb: ContextVerb) => void; onClose: () => void; }): React.JSX.Element
554
+ - MarqueeBox (function): function MarqueeBox({ rect }: { rect: ScreenRect }): React.JSX.Element
410
555
 
411
556
  ### @jgengine/shell/pointer/PointerProbe
412
557
 
@@ -414,118 +559,166 @@ Imports use deep paths: `@jgengine/shell/<path>`.
414
559
 
415
560
  ### @jgengine/shell/pointer/pointerService
416
561
 
417
- - createPointerService (function): function createPointerService(): PointerService
418
562
  - POINTER_ENTITY_KEY (const): const POINTER_ENTITY_KEY: "jgEntityId"
419
563
  - POINTER_OBJECT_KEY (const): const POINTER_OBJECT_KEY: "jgObjectId"
564
+ - PointerHitFilter (type): type PointerHitFilter = (object: THREE.Object3D) => boolean
420
565
  - PointerService (interface): interface PointerService
566
+ - createPointerService (function): function createPointerService(): PointerService
421
567
 
422
568
  ### @jgengine/shell/registry
423
569
 
424
- - resolveGameLoader (function): function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise<PlayableGame>) | undefined
570
+ - GameRegistry (type): type GameRegistry = Record<string, () => Promise<PlayableGame>>
571
+ - PlayableGame (type): type PlayableGame = EnginePlayableGame<ComponentType, ComponentType, RenderEntity, RenderObject>
425
572
  - RenderEntity (type): type RenderEntity = (entity: SceneEntity) => ReactNode
426
573
  - RenderObject (type): type RenderObject = (object: SceneObject) => ReactNode
427
- - PlayableGame (type): type PlayableGame = EnginePlayableGame<ComponentType, ComponentType, RenderEntity, RenderObject>
428
- - GameRegistry (type): type GameRegistry = Record<string, () => Promise<PlayableGame>>
574
+ - resolveGameLoader (function): function resolveGameLoader(registry: GameRegistry, gameId: string, fallbackGameId?: string): (() => Promise<PlayableGame>) | undefined
429
575
 
430
576
  ### @jgengine/shell/render/modelRender
431
577
 
432
- - cloneModelScene (function): function cloneModelScene(source: THREE.Object3D): THREE.Object3D
433
- - standardMaterialsOf (function): function standardMaterialsOf(root: THREE.Object3D): THREE.MeshStandardMaterial[]
578
+ - MaterialCache (interface): interface MaterialCache
579
+ - PAINT_TEXTURE_SIZE (const): const PAINT_TEXTURE_SIZE: 512
580
+ - PaintCanvas (interface): interface PaintCanvas
581
+ - applyPaintTexture (function): function applyPaintTexture(root: THREE.Object3D, paint: PaintCanvas): void
582
+ - applyPaintTextureToMaterials (function): function applyPaintTextureToMaterials(materials: readonly THREE.MeshStandardMaterial[], paint: PaintCanvas): void
583
+ - cacheStandardMaterials (function): function cacheStandardMaterials(root: THREE.Object3D, into?: MaterialCache | null): MaterialCache
584
+ - cloneModelScene (function): function cloneModelScene(source: THREE.Object3D, options?: { cloneMaterials?: boolean }): THREE.Object3D
434
585
  - createPaintCanvas (function): function createPaintCanvas(seed: THREE.MeshStandardMaterial, size = PAINT_TEXTURE_SIZE): PaintCanvas
586
+ - disposeClonedMaterials (function): function disposeClonedMaterials(root: THREE.Object3D): void
435
587
  - drawPaintStrokes (function): function drawPaintStrokes(paint: PaintCanvas, strokes: readonly PaintStroke[]): void
436
- - applyPaintTexture (function): function applyPaintTexture(root: THREE.Object3D, paint: PaintCanvas): void
588
+ - standardMaterialsOf (function): function standardMaterialsOf(root: THREE.Object3D): THREE.MeshStandardMaterial[]
437
589
  - syncPaintCanvas (function): function syncPaintCanvas(paint: PaintCanvas, seedColor: THREE.Color, strokes: readonly PaintStroke[], drawnCount: number): number
438
- - PAINT_TEXTURE_SIZE (const): const PAINT_TEXTURE_SIZE: 512
439
- - PaintCanvas (interface): interface PaintCanvas
590
+
591
+ ### @jgengine/shell/render/resolveModel
592
+
593
+ - ModelResolveContext (interface): interface ModelResolveContext
594
+ - resolveModel (function): function resolveModel(value: string | ModelConfig | undefined, assets: AssetCatalog, context?: ModelResolveContext): ModelConfig | undefined — Resolve a string asset id or a direct ModelConfig. Missing/misspelled catalog ids throw — silent generic-primitive fallback only happens when the mapping omits the key entirely (or uses tryResolveCatalogModel for optional ids).
595
+ - tryResolveCatalogModel (function): function tryResolveCatalogModel(id: string, assets: AssetCatalog): ModelConfig | undefined — Soft lookup used when an object catalog id may double as a model asset id.
440
596
 
441
597
  ### @jgengine/shell/replay/useSessionRecorder
442
598
 
443
- - useSessionRecorder (function): function useSessionRecorder(entityId: string, options?: RecordingBufferOptions): RecordingBuffer<RecordedPose> — Session-recording buffer (#120) for replay / photo mode / kill-cam: records an entity's pose on game-time every frame into a `RecordingBuffer`, which a game can then `seek()` to scrub, drive an observer cam ghost, or export a kill-cam clip. Recording rides on `ctx.time.now()`, so pause/fast-forward scrub the recording exactly like the live sim.
444
599
  - RecordedPose (interface): interface RecordedPose
600
+ - useSessionRecorder (function): function useSessionRecorder(entityId: string, options?: RecordingBufferOptions): RecordingBuffer<RecordedPose> — Session-recording buffer (#120) for replay / photo mode / kill-cam: records an entity's pose on game-time every frame into a `RecordingBuffer`, which a game can then `seek()` to scrub, drive an observer cam ghost, or export a kill-cam clip. Recording rides on `ctx.time.now()`, so pause/fast-forward scrub the recording exactly like the live sim.
601
+
602
+ ### @jgengine/shell/settings/QuickControls
603
+
604
+ - QuickControls (function): function QuickControls({ controller }: { controller: SettingsController }): React.JSX.Element | null
605
+
606
+ ### @jgengine/shell/settings/SettingsChrome
607
+
608
+ - SettingsChrome (function): function SettingsChrome(): React.JSX.Element | null
609
+
610
+ ### @jgengine/shell/settings/SettingsMenu
611
+
612
+ - SettingsMenu (function): function SettingsMenu({ controller, onClose, initialTab, }: { controller: SettingsController; onClose: () => void; initialTab?: string; }): React.JSX.Element | null
613
+
614
+ ### @jgengine/shell/settings/SettingsRuntime
615
+
616
+ - SettingsRuntime (function): function SettingsRuntime({ variant, surface, actions, children, ...input }: SettingsRuntimeProps): React.JSX.Element
617
+ - SettingsRuntimeProps (interface): interface SettingsRuntimeProps extends SettingsControllerInput
618
+
619
+ ### @jgengine/shell/settings/appliedSettings
620
+
621
+ - AudioSettingsBridge (function): function AudioSettingsBridge({ store, engine, buses, }: { store: SettingsStore; engine: AudioEngine; buses: Record<string, AudioBusDef> | undefined; }): null
622
+ - useGraphicsSettings (function): function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean): { shadows: boolean; dpr: number; uiScale: number }
623
+ - useSettingsRevision (function): function useSettingsRevision(store: SettingsStore): number
624
+
625
+ ### @jgengine/shell/settings/settingsController
626
+
627
+ - SettingsControllerInput (interface): interface SettingsControllerInput
628
+ - useSettingsCategories (function): function useSettingsCategories(config: SettingsControllerInput): SettingsCategoryView[]
445
629
 
446
630
  ### @jgengine/shell/structures
447
631
 
448
632
  - BuildingBlock (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element
449
- - GeneratedBuilding (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element
450
633
  - BuildingBlockProps (interface): interface BuildingBlockProps
634
+ - GeneratedBuilding (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element
451
635
  - GeneratedBuildingProps (interface): interface GeneratedBuildingProps
636
+ - InstancedBuildingPlacement (interface): interface InstancedBuildingPlacement
637
+ - InstancedBuildings (function): function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): React.JSX.Element | null
638
+ - InstancedBuildingsProps (interface): interface InstancedBuildingsProps
452
639
  - PlacementGhost (function): function PlacementGhost({ preview, height = 1, validColor = "#34d399", invalidColor = "#f87171", }: PlacementGhostProps): React.JSX.Element | null
453
640
  - PlacementGhostProps (interface): interface PlacementGhostProps
454
641
 
455
642
  ### @jgengine/shell/structures/GeneratedBuilding
456
643
 
457
644
  - BuildingBlock (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element
458
- - GeneratedBuilding (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element
645
+ - BuildingBlockProps (interface): interface BuildingBlockProps
459
646
  - BuildingFacade (type): type BuildingFacade = "front" | "back" | "left" | "right" | "roof"
647
+ - BuildingKitRenderer (interface): interface BuildingKitRenderer
648
+ - BuildingMaterialPalette (interface): interface BuildingMaterialPalette
460
649
  - BuildingPartKind (type): type BuildingPartKind = | "wall" | "window" | "awning" | "airConditioner" | "clothesline" | "storefront" | "shutter" | "storeSign" | "roof" | "roofProp" | "guardrail" | "corner"
461
650
  - BuildingPartPlacement (interface): interface BuildingPartPlacement
651
+ - GeneratedBuilding (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element
462
652
  - GeneratedBuildingData (interface): interface GeneratedBuildingData
463
- - BuildingMaterialPalette (interface): interface BuildingMaterialPalette
464
- - BuildingKitRenderer (interface): interface BuildingKitRenderer
465
- - BuildingBlockProps (interface): interface BuildingBlockProps
466
653
  - GeneratedBuildingProps (interface): interface GeneratedBuildingProps
654
+ - InstancedBuildingPlacement (interface): interface InstancedBuildingPlacement
655
+ - InstancedBuildings (function): function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): React.JSX.Element | null
656
+ - InstancedBuildingsProps (interface): interface InstancedBuildingsProps
467
657
 
468
- ### @jgengine/shell/structures/index
658
+ ### @jgengine/shell/structures/PlacementGhost
469
659
 
470
- - BuildingBlock (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element
471
- - GeneratedBuilding (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element
472
- - BuildingBlockProps (interface): interface BuildingBlockProps
473
- - GeneratedBuildingProps (interface): interface GeneratedBuildingProps
474
660
  - PlacementGhost (function): function PlacementGhost({ preview, height = 1, validColor = "#34d399", invalidColor = "#f87171", }: PlacementGhostProps): React.JSX.Element | null
475
661
  - PlacementGhostProps (interface): interface PlacementGhostProps
476
662
 
477
- ### @jgengine/shell/structures/PlacementGhost
663
+ ### @jgengine/shell/structures/index
478
664
 
665
+ - BuildingBlock (function): function BuildingBlock({ part, palette }: BuildingBlockProps): React.JSX.Element
666
+ - BuildingBlockProps (interface): interface BuildingBlockProps
667
+ - GeneratedBuilding (function): function GeneratedBuilding({ building, palette, kit, visibleKinds }: GeneratedBuildingProps): React.JSX.Element
668
+ - GeneratedBuildingProps (interface): interface GeneratedBuildingProps
669
+ - InstancedBuildingPlacement (interface): interface InstancedBuildingPlacement
670
+ - InstancedBuildings (function): function InstancedBuildings({ buildings, palette, visibleKinds }: InstancedBuildingsProps): React.JSX.Element | null
671
+ - InstancedBuildingsProps (interface): interface InstancedBuildingsProps
479
672
  - PlacementGhost (function): function PlacementGhost({ preview, height = 1, validColor = "#34d399", invalidColor = "#f87171", }: PlacementGhostProps): React.JSX.Element | null
480
673
  - PlacementGhostProps (interface): interface PlacementGhostProps
481
674
 
482
675
  ### @jgengine/shell/terrain
483
676
 
484
- - CarvedTerrain (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
677
+ - CarvedTerrain (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element — Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
485
678
  - CarvedTerrainProps (interface): interface CarvedTerrainProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
486
- - GrassField (function): function GrassField({ count = 6000, density = 1, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = false, ...meshProps }: GrassFieldProps): React.JSX.Element
487
- - GrassFieldProps (interface): interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
488
- - ProceduralGround (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element
489
- - ProceduralGroundProps (interface): interface ProceduralGroundProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
679
+ - DEFAULT_GRASS_WIND (const): const DEFAULT_GRASS_WIND: Required<GrassWindOptions>
490
680
  - EditableGround (function): function EditableGround({ terrain, bounds, segments = 96, version = 0, baseColor = "#3f6b3a", surfaceColors = DEFAULT_SURFACE_COLORS, }: EditableGroundProps): React.JSX.Element
491
681
  - EditableGroundProps (interface): interface EditableGroundProps
492
- - TerraformBrushCursor (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null
493
- - TerraformBrushCursorProps (interface): interface TerraformBrushCursorProps
494
- - createGrassBladeGeometry (function): function createGrassBladeGeometry(options: GrassBladeGeometryOptions = {}): THREE.InstancedBufferGeometry
495
- - resolveGrassBladeGeometryOptions (function): function resolveGrassBladeGeometryOptions(options: GrassBladeGeometryOptions = {}): ResolvedGrassBladeGeometryOptions
496
- - resolveGrassRange (function): function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number]
682
+ - FieldGroundOptions (interface): interface FieldGroundOptions
497
683
  - GrassBladeGeometryOptions (interface): interface GrassBladeGeometryOptions
498
- - GrassRange (type): type GrassRange = number | readonly [min: number, max: number]
499
- - ResolvedGrassBladeGeometryOptions (interface): interface ResolvedGrassBladeGeometryOptions
500
- - createGrassMaterial (function): function createGrassMaterial(options: GrassMaterialOptions = {}): GrassMaterialHandle
501
- - DEFAULT_GRASS_WIND (const): const DEFAULT_GRASS_WIND: Required<GrassWindOptions>
502
- - resolveGrassWind (function): function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>
684
+ - GrassField (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr…
685
+ - GrassFieldProps (interface): interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
503
686
  - GrassMaterialHandle (interface): interface GrassMaterialHandle
504
687
  - GrassMaterialOptions (interface): interface GrassMaterialOptions
688
+ - GrassRange (type): type GrassRange = number | readonly [min: number, max: number]
505
689
  - GrassShaderUniforms (interface): interface GrassShaderUniforms
506
690
  - GrassWindOptions (interface): interface GrassWindOptions
507
- - createSeededRandom (function): function createSeededRandom(seed: TerrainSeed = 1): () => number
508
- - hashNoise2 (function): function hashNoise2(x: number, z: number, seed: TerrainSeed = 1): number
509
- - seedToUint32 (function): function seedToUint32(seed: TerrainSeed = 1): number
691
+ - ProceduralGround (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element
692
+ - ProceduralGroundProps (interface): interface ProceduralGroundProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
693
+ - ProceduralTerrainConfig (interface): interface ProceduralTerrainConfig
694
+ - ResolvedGrassBladeGeometryOptions (interface): interface ResolvedGrassBladeGeometryOptions
695
+ - ResolvedTerrainSegments (interface): interface ResolvedTerrainSegments
696
+ - ResolvedTerrainSize (interface): interface ResolvedTerrainSize
697
+ - TerraformBrushCursor (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null
698
+ - TerraformBrushCursorProps (interface): interface TerraformBrushCursorProps
699
+ - TerrainArea (type): type TerrainArea = number | readonly [width: number, depth: number]
700
+ - TerrainHeightSampler (type): type TerrainHeightSampler = (x: number, z: number) => number
510
701
  - TerrainSeed (type): type TerrainSeed = number | string
511
- - createFieldGroundGeometry (function): function createFieldGroundGeometry(field: TerrainField, options: FieldGroundOptions = {}): THREE.BufferGeometry — Mesh any `TerrainField` — including a `CarvableField` with craters/mounds written into it — into a vertex-coloured ground geometry. `sampleHeight` drives the vertices, so runtime carves show up as real depressions the moment the field is re-sampled (bump the caller's rebuild key after a carve).
702
+ - TerrainVertexColorOptions (interface): interface TerrainVertexColorOptions
703
+ - createFieldGroundGeometry (function): function createFieldGroundGeometry(field: TerrainField, options: FieldGroundOptions = {}): THREE.BufferGeometry — Mesh any `TerrainField` — including a `CarvableField` with craters/mounds written into it — into a vertex-coloured ground geometry. `sampleHeight` drives the vertices, so runtime carves show up as real depressions the moment the field is re-sampled (bump the caller's rebuild key after a carve).
704
+ - createGrassBladeGeometry (function): function createGrassBladeGeometry(options: GrassBladeGeometryOptions = {}): THREE.InstancedBufferGeometry
705
+ - createGrassMaterial (function): function createGrassMaterial(options: GrassMaterialOptions = {}): GrassMaterialHandle
512
706
  - createProceduralGroundGeometry (function): function createProceduralGroundGeometry(config: ProceduralTerrainConfig = {}, colors: TerrainVertexColorOptions = {}): THREE.BufferGeometry
513
707
  - createProceduralTerrainSampler (function): function createProceduralTerrainSampler(config: ProceduralTerrainConfig = {}): TerrainHeightSampler
708
+ - createSeededRandom (function): function createSeededRandom(seed: TerrainSeed = 1): () => number
709
+ - hashNoise2 (function): function hashNoise2(x: number, z: number, seed: TerrainSeed = 1): number
514
710
  - normalizeHeightBlend (function): function normalizeHeightBlend(height: number, minHeight: number, maxHeight: number): number
711
+ - resolveGrassBladeGeometryOptions (function): function resolveGrassBladeGeometryOptions(options: GrassBladeGeometryOptions = {}): ResolvedGrassBladeGeometryOptions
712
+ - resolveGrassRange (function): function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number]
713
+ - resolveGrassWind (function): function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>
515
714
  - resolveTerrainSegments (function): function resolveTerrainSegments(segments: ProceduralTerrainConfig["segments"] = 96): ResolvedTerrainSegments
516
715
  - resolveTerrainSize (function): function resolveTerrainSize(size: TerrainArea = 40): ResolvedTerrainSize
716
+ - seedToUint32 (function): function seedToUint32(seed: TerrainSeed = 1): number
517
717
  - toNoiseFieldConfig (function): function toNoiseFieldConfig(config: ProceduralTerrainConfig = {}): NoiseFieldConfig
518
- - FieldGroundOptions (interface): interface FieldGroundOptions
519
- - ProceduralTerrainConfig (interface): interface ProceduralTerrainConfig
520
- - ResolvedTerrainSegments (interface): interface ResolvedTerrainSegments
521
- - ResolvedTerrainSize (interface): interface ResolvedTerrainSize
522
- - TerrainArea (type): type TerrainArea = number | readonly [width: number, depth: number]
523
- - TerrainHeightSampler (type): type TerrainHeightSampler = (x: number, z: number) => number
524
- - TerrainVertexColorOptions (interface): interface TerrainVertexColorOptions
525
718
 
526
719
  ### @jgengine/shell/terrain/CarvedTerrain
527
720
 
528
- - CarvedTerrain (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
721
+ - CarvedTerrain (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element — Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
529
722
  - CarvedTerrainProps (interface): interface CarvedTerrainProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
530
723
 
531
724
  ### @jgengine/shell/terrain/EditableGround
@@ -535,167 +728,196 @@ Imports use deep paths: `@jgengine/shell/<path>`.
535
728
 
536
729
  ### @jgengine/shell/terrain/GrassField
537
730
 
538
- - GrassField (function): function GrassField({ count = 6000, density = 1, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = false, ...meshProps }: GrassFieldProps): React.JSX.Element
731
+ - DEFAULT_GRASS_COUNT (const): const DEFAULT_GRASS_COUNT: 1500
732
+ - DEFAULT_GRASS_DENSITY (const): const DEFAULT_GRASS_DENSITY: 1
733
+ - GrassField (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr…
539
734
  - GrassFieldProps (interface): interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
735
+ - resolveGrassInstanceBudget (function): function resolveGrassInstanceBudget(count: number, density: number, budget?: number): number
736
+
737
+ ### @jgengine/shell/terrain/ProceduralGround
738
+
739
+ - ProceduralGround (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element
740
+ - ProceduralGroundProps (interface): interface ProceduralGroundProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
741
+
742
+ ### @jgengine/shell/terrain/TerraformBrushCursor
743
+
744
+ - TerraformBrushCursor (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null
745
+ - TerraformBrushCursorProps (interface): interface TerraformBrushCursorProps
746
+
747
+ ### @jgengine/shell/terrain/grassBudget
748
+
749
+ - DEFAULT_GRASS_COUNT (const): const DEFAULT_GRASS_COUNT: 1500
750
+ - DEFAULT_GRASS_DENSITY (const): const DEFAULT_GRASS_DENSITY: 1
751
+ - resolveGrassInstanceBudget (function): function resolveGrassInstanceBudget(count: number, density: number, budget?: number): number
540
752
 
541
753
  ### @jgengine/shell/terrain/grassGeometry
542
754
 
543
- - resolveGrassRange (function): function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number]
544
- - resolveGrassBladeGeometryOptions (function): function resolveGrassBladeGeometryOptions(options: GrassBladeGeometryOptions = {}): ResolvedGrassBladeGeometryOptions
545
- - createGrassBladeGeometry (function): function createGrassBladeGeometry(options: GrassBladeGeometryOptions = {}): THREE.InstancedBufferGeometry
546
- - GrassRange (type): type GrassRange = number | readonly [min: number, max: number]
547
755
  - GrassBladeGeometryOptions (interface): interface GrassBladeGeometryOptions
756
+ - GrassRange (type): type GrassRange = number | readonly [min: number, max: number]
548
757
  - ResolvedGrassBladeGeometryOptions (interface): interface ResolvedGrassBladeGeometryOptions
758
+ - createGrassBladeGeometry (function): function createGrassBladeGeometry(options: GrassBladeGeometryOptions = {}): THREE.InstancedBufferGeometry
759
+ - resolveGrassBladeGeometryOptions (function): function resolveGrassBladeGeometryOptions(options: GrassBladeGeometryOptions = {}): ResolvedGrassBladeGeometryOptions
760
+ - resolveGrassRange (function): function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number]
549
761
 
550
762
  ### @jgengine/shell/terrain/grassMaterial
551
763
 
552
- - resolveGrassWind (function): function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>
553
- - createGrassMaterial (function): function createGrassMaterial(options: GrassMaterialOptions = {}): GrassMaterialHandle
554
- - GrassWindOptions (interface): interface GrassWindOptions
764
+ - DEFAULT_GRASS_WIND (const): const DEFAULT_GRASS_WIND: Required<GrassWindOptions>
765
+ - GrassMaterialHandle (interface): interface GrassMaterialHandle
555
766
  - GrassMaterialOptions (interface): interface GrassMaterialOptions
556
767
  - GrassShaderUniforms (interface): interface GrassShaderUniforms
557
- - GrassMaterialHandle (interface): interface GrassMaterialHandle
558
- - DEFAULT_GRASS_WIND (const): const DEFAULT_GRASS_WIND: Required<GrassWindOptions>
768
+ - GrassWindOptions (interface): interface GrassWindOptions
769
+ - createGrassMaterial (function): function createGrassMaterial(options: GrassMaterialOptions = {}): GrassMaterialHandle
770
+ - resolveGrassWind (function): function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>
559
771
 
560
772
  ### @jgengine/shell/terrain/index
561
773
 
562
- - CarvedTerrain (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
774
+ - CarvedTerrain (function): function CarvedTerrain({ field, size, segments, center, colors, heightRange, paletteAt, roughness = 0.95, metalness = 0, receiveShadow = true, epoch = 0, ...meshProps }: CarvedTerrainProps): React.JSX.Element — Renders a `TerrainField` as a deformed ground mesh — the crater/mound view for destructible terrain. Because the geometry samples `field.sampleHeight`, a `CarvableField.carve(...)` shows as a real bowl once `epoch` changes. Pair with `InstancedBodies` to see debris resting in the crater it blasted.
563
775
  - CarvedTerrainProps (interface): interface CarvedTerrainProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
564
- - GrassField (function): function GrassField({ count = 6000, density = 1, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = false, ...meshProps }: GrassFieldProps): React.JSX.Element
565
- - GrassFieldProps (interface): interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
566
- - ProceduralGround (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element
567
- - ProceduralGroundProps (interface): interface ProceduralGroundProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
776
+ - DEFAULT_GRASS_WIND (const): const DEFAULT_GRASS_WIND: Required<GrassWindOptions>
568
777
  - EditableGround (function): function EditableGround({ terrain, bounds, segments = 96, version = 0, baseColor = "#3f6b3a", surfaceColors = DEFAULT_SURFACE_COLORS, }: EditableGroundProps): React.JSX.Element
569
778
  - EditableGroundProps (interface): interface EditableGroundProps
570
- - TerraformBrushCursor (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null
571
- - TerraformBrushCursorProps (interface): interface TerraformBrushCursorProps
572
- - createGrassBladeGeometry (function): function createGrassBladeGeometry(options: GrassBladeGeometryOptions = {}): THREE.InstancedBufferGeometry
573
- - resolveGrassBladeGeometryOptions (function): function resolveGrassBladeGeometryOptions(options: GrassBladeGeometryOptions = {}): ResolvedGrassBladeGeometryOptions
574
- - resolveGrassRange (function): function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number]
779
+ - FieldGroundOptions (interface): interface FieldGroundOptions
575
780
  - GrassBladeGeometryOptions (interface): interface GrassBladeGeometryOptions
576
- - GrassRange (type): type GrassRange = number | readonly [min: number, max: number]
577
- - ResolvedGrassBladeGeometryOptions (interface): interface ResolvedGrassBladeGeometryOptions
578
- - createGrassMaterial (function): function createGrassMaterial(options: GrassMaterialOptions = {}): GrassMaterialHandle
579
- - DEFAULT_GRASS_WIND (const): const DEFAULT_GRASS_WIND: Required<GrassWindOptions>
580
- - resolveGrassWind (function): function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>
781
+ - GrassField (function): function GrassField({ count = DEFAULT_GRASS_COUNT, density = DEFAULT_GRASS_DENSITY, budget, area = 40, seed = 1, segments = 4, bladeHeight, bladeWidth, bladeBend, heightAt, colorBase, colorTip, colorVariation, wind, roughness, castShadow = false, receiveShadow = true, frustumCulled = true, ...meshPr…
782
+ - GrassFieldProps (interface): interface GrassFieldProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
581
783
  - GrassMaterialHandle (interface): interface GrassMaterialHandle
582
784
  - GrassMaterialOptions (interface): interface GrassMaterialOptions
785
+ - GrassRange (type): type GrassRange = number | readonly [min: number, max: number]
583
786
  - GrassShaderUniforms (interface): interface GrassShaderUniforms
584
787
  - GrassWindOptions (interface): interface GrassWindOptions
585
- - createSeededRandom (function): function createSeededRandom(seed: TerrainSeed = 1): () => number
586
- - hashNoise2 (function): function hashNoise2(x: number, z: number, seed: TerrainSeed = 1): number
587
- - seedToUint32 (function): function seedToUint32(seed: TerrainSeed = 1): number
788
+ - ProceduralGround (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element
789
+ - ProceduralGroundProps (interface): interface ProceduralGroundProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
790
+ - ProceduralTerrainConfig (interface): interface ProceduralTerrainConfig
791
+ - ResolvedGrassBladeGeometryOptions (interface): interface ResolvedGrassBladeGeometryOptions
792
+ - ResolvedTerrainSegments (interface): interface ResolvedTerrainSegments
793
+ - ResolvedTerrainSize (interface): interface ResolvedTerrainSize
794
+ - TerraformBrushCursor (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null
795
+ - TerraformBrushCursorProps (interface): interface TerraformBrushCursorProps
796
+ - TerrainArea (type): type TerrainArea = number | readonly [width: number, depth: number]
797
+ - TerrainHeightSampler (type): type TerrainHeightSampler = (x: number, z: number) => number
588
798
  - TerrainSeed (type): type TerrainSeed = number | string
589
- - createFieldGroundGeometry (function): function createFieldGroundGeometry(field: TerrainField, options: FieldGroundOptions = {}): THREE.BufferGeometry — Mesh any `TerrainField` — including a `CarvableField` with craters/mounds written into it — into a vertex-coloured ground geometry. `sampleHeight` drives the vertices, so runtime carves show up as real depressions the moment the field is re-sampled (bump the caller's rebuild key after a carve).
799
+ - TerrainVertexColorOptions (interface): interface TerrainVertexColorOptions
800
+ - createFieldGroundGeometry (function): function createFieldGroundGeometry(field: TerrainField, options: FieldGroundOptions = {}): THREE.BufferGeometry — Mesh any `TerrainField` — including a `CarvableField` with craters/mounds written into it — into a vertex-coloured ground geometry. `sampleHeight` drives the vertices, so runtime carves show up as real depressions the moment the field is re-sampled (bump the caller's rebuild key after a carve).
801
+ - createGrassBladeGeometry (function): function createGrassBladeGeometry(options: GrassBladeGeometryOptions = {}): THREE.InstancedBufferGeometry
802
+ - createGrassMaterial (function): function createGrassMaterial(options: GrassMaterialOptions = {}): GrassMaterialHandle
590
803
  - createProceduralGroundGeometry (function): function createProceduralGroundGeometry(config: ProceduralTerrainConfig = {}, colors: TerrainVertexColorOptions = {}): THREE.BufferGeometry
591
804
  - createProceduralTerrainSampler (function): function createProceduralTerrainSampler(config: ProceduralTerrainConfig = {}): TerrainHeightSampler
805
+ - createSeededRandom (function): function createSeededRandom(seed: TerrainSeed = 1): () => number
806
+ - hashNoise2 (function): function hashNoise2(x: number, z: number, seed: TerrainSeed = 1): number
592
807
  - normalizeHeightBlend (function): function normalizeHeightBlend(height: number, minHeight: number, maxHeight: number): number
808
+ - resolveGrassBladeGeometryOptions (function): function resolveGrassBladeGeometryOptions(options: GrassBladeGeometryOptions = {}): ResolvedGrassBladeGeometryOptions
809
+ - resolveGrassRange (function): function resolveGrassRange(value: GrassRange | undefined, fallback: readonly [number, number]): readonly [number, number]
810
+ - resolveGrassWind (function): function resolveGrassWind(wind: GrassWindOptions | false | undefined): Required<GrassWindOptions>
593
811
  - resolveTerrainSegments (function): function resolveTerrainSegments(segments: ProceduralTerrainConfig["segments"] = 96): ResolvedTerrainSegments
594
812
  - resolveTerrainSize (function): function resolveTerrainSize(size: TerrainArea = 40): ResolvedTerrainSize
813
+ - seedToUint32 (function): function seedToUint32(seed: TerrainSeed = 1): number
595
814
  - toNoiseFieldConfig (function): function toNoiseFieldConfig(config: ProceduralTerrainConfig = {}): NoiseFieldConfig
596
- - FieldGroundOptions (interface): interface FieldGroundOptions
597
- - ProceduralTerrainConfig (interface): interface ProceduralTerrainConfig
598
- - ResolvedTerrainSegments (interface): interface ResolvedTerrainSegments
599
- - ResolvedTerrainSize (interface): interface ResolvedTerrainSize
600
- - TerrainArea (type): type TerrainArea = number | readonly [width: number, depth: number]
601
- - TerrainHeightSampler (type): type TerrainHeightSampler = (x: number, z: number) => number
602
- - TerrainVertexColorOptions (interface): interface TerrainVertexColorOptions
603
-
604
- ### @jgengine/shell/terrain/ProceduralGround
605
-
606
- - ProceduralGround (function): function ProceduralGround({ terrain, colors, roughness = 0.94, metalness = 0, receiveShadow = true, ...meshProps }: ProceduralGroundProps): React.JSX.Element
607
- - ProceduralGroundProps (interface): interface ProceduralGroundProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
608
815
 
609
816
  ### @jgengine/shell/terrain/random
610
817
 
611
- - seedToUint32 (function): function seedToUint32(seed: TerrainSeed = 1): number
818
+ - TerrainSeed (type): type TerrainSeed = number | string
612
819
  - createSeededRandom (function): function createSeededRandom(seed: TerrainSeed = 1): () => number
613
820
  - hashNoise2 (function): function hashNoise2(x: number, z: number, seed: TerrainSeed = 1): number
614
- - TerrainSeed (type): type TerrainSeed = number | string
615
-
616
- ### @jgengine/shell/terrain/TerraformBrushCursor
617
-
618
- - TerraformBrushCursor (function): function TerraformBrushCursor({ center, y = 0.05, radius, mode }: TerraformBrushCursorProps): React.JSX.Element | null
619
- - TerraformBrushCursorProps (interface): interface TerraformBrushCursorProps
821
+ - seedToUint32 (function): function seedToUint32(seed: TerrainSeed = 1): number
620
822
 
621
823
  ### @jgengine/shell/terrain/terrainMath
622
824
 
623
- - normalizeHeightBlend (function): function normalizeHeightBlend(height: number, minHeight: number, maxHeight: number): number
624
- - resolveTerrainSize (function): function resolveTerrainSize(size: TerrainArea = 40): ResolvedTerrainSize
625
- - resolveTerrainSegments (function): function resolveTerrainSegments(segments: ProceduralTerrainConfig["segments"] = 96): ResolvedTerrainSegments
626
- - toNoiseFieldConfig (function): function toNoiseFieldConfig(config: ProceduralTerrainConfig = {}): NoiseFieldConfig
627
- - createProceduralTerrainSampler (function): function createProceduralTerrainSampler(config: ProceduralTerrainConfig = {}): TerrainHeightSampler
628
- - createFieldGroundGeometry (function): function createFieldGroundGeometry(field: TerrainField, options: FieldGroundOptions = {}): THREE.BufferGeometry — Mesh any `TerrainField` — including a `CarvableField` with craters/mounds written into it — into a vertex-coloured ground geometry. `sampleHeight` drives the vertices, so runtime carves show up as real depressions the moment the field is re-sampled (bump the caller's rebuild key after a carve).
629
- - createProceduralGroundGeometry (function): function createProceduralGroundGeometry(config: ProceduralTerrainConfig = {}, colors: TerrainVertexColorOptions = {}): THREE.BufferGeometry
630
- - TerrainArea (type): type TerrainArea = number | readonly [width: number, depth: number]
631
- - TerrainHeightSampler (type): type TerrainHeightSampler = (x: number, z: number) => number
825
+ - FieldGroundOptions (interface): interface FieldGroundOptions
632
826
  - ProceduralTerrainConfig (interface): interface ProceduralTerrainConfig
633
- - ResolvedTerrainSize (interface): interface ResolvedTerrainSize
634
827
  - ResolvedTerrainSegments (interface): interface ResolvedTerrainSegments
828
+ - ResolvedTerrainSize (interface): interface ResolvedTerrainSize
829
+ - TerrainArea (type): type TerrainArea = number | readonly [width: number, depth: number]
830
+ - TerrainHeightSampler (type): type TerrainHeightSampler = (x: number, z: number) => number
831
+ - TerrainPaletteSampler (type): type TerrainPaletteSampler = (x: number, z: number) => { low: string; high: string; waterline?: string } — Per-position palette override for multi-biome ground coloring — `createTerrainPaletteSampler` from `@jgengine/core/world/terrain` returns exactly this shape.
635
832
  - TerrainVertexColorOptions (interface): interface TerrainVertexColorOptions
636
- - FieldGroundOptions (interface): interface FieldGroundOptions
833
+ - createFieldGroundGeometry (function): function createFieldGroundGeometry(field: TerrainField, options: FieldGroundOptions = {}): THREE.BufferGeometry — Mesh any `TerrainField` — including a `CarvableField` with craters/mounds written into it — into a vertex-coloured ground geometry. `sampleHeight` drives the vertices, so runtime carves show up as real depressions the moment the field is re-sampled (bump the caller's rebuild key after a carve).
834
+ - createProceduralGroundGeometry (function): function createProceduralGroundGeometry(config: ProceduralTerrainConfig = {}, colors: TerrainVertexColorOptions = {}): THREE.BufferGeometry
835
+ - createProceduralTerrainSampler (function): function createProceduralTerrainSampler(config: ProceduralTerrainConfig = {}): TerrainHeightSampler
836
+ - normalizeHeightBlend (function): function normalizeHeightBlend(height: number, minHeight: number, maxHeight: number): number
837
+ - resolveTerrainSegments (function): function resolveTerrainSegments(segments: ProceduralTerrainConfig["segments"] = 96): ResolvedTerrainSegments
838
+ - resolveTerrainSize (function): function resolveTerrainSize(size: TerrainArea = 40): ResolvedTerrainSize
839
+ - toNoiseFieldConfig (function): function toNoiseFieldConfig(config: ProceduralTerrainConfig = {}): NoiseFieldConfig
840
+
841
+ ### @jgengine/shell/touch/OrientationHint
842
+
843
+ - OrientationHint (function): function OrientationHint({ wanted }: { wanted: "landscape" | "portrait" }): React.JSX.Element | null
637
844
 
638
845
  ### @jgengine/shell/touch/TouchControlsOverlay
639
846
 
640
- - TouchPlaySurface (function): function TouchPlaySurface({ scheme, sink, yawRef, pitchRef, maxPitch, onPrimaryTap, }: { scheme: TouchScheme; sink: TouchCodeSink; yawRef: MutableRefObject<number>; pitchRef: MutableRefObject<number>; maxPitch: number; onPrimaryTap: () => void; }): React.JSX.Element
641
- - TouchControlsDock (function): function TouchControlsDock({ scheme, sink }: { scheme: TouchScheme; sink: TouchCodeSink }): React.JSX.Element
642
847
  - TouchCodeSink (interface): interface TouchCodeSink
848
+ - TouchControlsDock (function): function TouchControlsDock({ scheme, sink, scale = 1 }: { scheme: TouchScheme; sink: TouchCodeSink; scale?: number }): React.JSX.Element
849
+ - TouchPlaySurface (function): function TouchPlaySurface({ scheme, sink, yawRef, pitchRef, maxPitch, onPrimaryTap, }: { scheme: TouchScheme; sink: TouchCodeSink; yawRef: MutableRefObject<number>; pitchRef: MutableRefObject<number>; maxPitch: number; onPrimaryTap: () => void; }): React.JSX.Element
850
+ - primaryButtonOffsets (function): function primaryButtonOffsets(count: number, scale = 1): { right: number; bottom: number }[] | null — Thumb-arc placement for primary buttons around the bottom-right corner: up to three on an inner ring, the rest on an outer ring. Null means too many buttons for an arc — the dock falls back to a wrapping grid.
851
+ - touchDockClearance (function): function touchDockClearance(scheme: TouchScheme | null, scale = 1): number — Vertical space (px, excluding device safe areas) the dock occupies above the bottom edge. The shell publishes it as `--jg-hud-dock-clearance` so `HudCanvas` regions never collide with touch controls.
852
+
853
+ ### @jgengine/shell/visibility/CullingProvider
854
+
855
+ - CullingProvider (function): function CullingProvider({ config, children }: { config: VisibilityConfig | undefined; children: ReactNode }): ReactNode — Drives automatic frustum + distance culling for every entity and placed object. It reads the live render camera each frame, updates the engine VisibilitySystem, and exposes a predicate the entity/object markers consult to toggle `group.visible` — objects fully outside the view (plus a conservative preload margin) are never submitted to the renderer, without unmounting them or touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
856
+ - useRenderVisibility (function): function useRenderVisibility(): MutableRefObject<VisiblePredicate> — Read the current render-visibility predicate. Backward compatible: with no CullingProvider mounted (or culling disabled) it returns an always-visible ref, so a marker that consults it behaves exactly as before this feature existed.
643
857
 
644
858
  ### @jgengine/shell/vision/FrustumSensorHud
645
859
 
646
- - useFrustumSensor (function): function useFrustumSensor(options: FrustumSensorProbeOptions): FrustumSample | null — View-frustum sensor (#117) driven by the live render camera: which held-camera subjects are in frame, how well framed, and their dwell time on-screen (Content Warning-style "is this monster filmed well" scoring). Returns the best-framed in-view subject each frame for a photo-mode HUD readout.
647
- - FrustumSensorReadout (function): function FrustumSensorReadout(props: FrustumSensorProbeOptions & { wrapperClassName?: string; className?: string }): React.JSX.Element — Renders inside the Canvas (needs the live camera via `useFrame`/`useThree`) but portals a real HTML readout via drei's `Html fullscreen` — a photo-mode "is this subject framed" HUD.
648
860
  - FrustumSensorProbeOptions (interface): interface FrustumSensorProbeOptions extends FramingConfig
861
+ - FrustumSensorReadout (function): function FrustumSensorReadout(props: FrustumSensorProbeOptions & { wrapperClassName?: string; className?: string }): React.JSX.Element — Renders inside the Canvas (needs the live camera via `useFrame`/`useThree`) but portals a real HTML readout via drei's `Html fullscreen` — a photo-mode "is this subject framed" HUD.
862
+ - frustumSampleDisplayEqual (function): function frustumSampleDisplayEqual(a: FrustumSample | null, b: FrustumSample | null): boolean
863
+ - useFrustumSensor (function): function useFrustumSensor(options: FrustumSensorProbeOptions): FrustumSample | null
649
864
 
650
865
  ### @jgengine/shell/vision/HiddenStateProbeHud
651
866
 
652
- - useHiddenStateProbe (function): function useHiddenStateProbe(origin: EntityPosition, sources: readonly HiddenStateSource[], options: SensorProbeOptions): any Reads a hidden zone/entity state variable in range (EMF / thermometer / geiger style sensor verb, #116).
653
- - SensorReadoutMeter (function): function SensorReadoutMeter({ label, reading, className }: SensorReadoutMeterProps): React.JSX.Element — A handheld-sensor readout: needle strength bar + the raw reading, or a "no signal" idle state.
867
+ - SensorReadoutMeter (function): function SensorReadoutMeter({ label, reading, className }: SensorReadoutMeterProps): React.JSX.Element — A handheld-sensor readout: needle strength bar + the raw reading, or a "no signal" idle state.
654
868
  - SensorReadoutMeterProps (interface): interface SensorReadoutMeterProps
869
+ - useHiddenStateProbe (function): function useHiddenStateProbe(origin: EntityPosition, sources: readonly HiddenStateSource[], options: SensorProbeOptions): any — Reads a hidden zone/entity state variable in range (EMF / thermometer / geiger style sensor verb, #116).
655
870
 
656
871
  ### @jgengine/shell/vision/RevealVision
657
872
 
658
- - useRevealHits (function): function useRevealHits(options: RevealVisionOptions): readonly RevealHit[] Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene.
659
- - RevealHighlights (function): function RevealHighlights(props: RevealHighlightsProps): React.JSX.Element | null — Screen-space reveal effect (#115) — highlights tagged entities through occluders (Dark Sight / detective-vision / wallhack style). Renders with `depthTest: false` so the highlight draws over any wall standing between the origin and the revealed entity, rather than the usual depth-sorted scene.
660
- - RevealScreenTint (function): function RevealScreenTint({ enabled, color = "rgba(56, 189, 248, 0.16)", className }: RevealScreenTintProps): React.JSX.Element | null — Full-screen desaturating tint that reads as "vision mode is on" (Dark Sight / thermal / detective vision).
661
- - RevealVisionOptions (interface): interface RevealVisionOptions
873
+ - RevealHighlights (function): function RevealHighlights(props: RevealHighlightsProps): React.JSX.Element | null — Screen-space reveal effect (#115) — highlights tagged entities through occluders (Dark Sight / detective-vision / wallhack style). Renders with `depthTest: false` so the highlight draws over any wall standing between the origin and the revealed entity, rather than the usual depth-sorted scene.
662
874
  - RevealHighlightsProps (interface): interface RevealHighlightsProps extends RevealVisionOptions
875
+ - RevealScreenTint (function): function RevealScreenTint({ enabled, color = "rgba(56, 189, 248, 0.16)", className }: RevealScreenTintProps): React.JSX.Element | null — Full-screen desaturating tint that reads as "vision mode is on" (Dark Sight / thermal / detective vision).
663
876
  - RevealScreenTintProps (interface): interface RevealScreenTintProps
877
+ - RevealVisionOptions (interface): interface RevealVisionOptions
878
+ - useRevealHits (function): function useRevealHits(options: RevealVisionOptions): readonly RevealHit[] — Occlusion-ignoring tagged-entity radius query (#115), bound to the live scene.
879
+
880
+ ### @jgengine/shell/vision/frustumSampleEqual
881
+
882
+ - frustumSampleDisplayEqual (function): function frustumSampleDisplayEqual(a: FrustumSample | null, b: FrustumSample | null): boolean
664
883
 
665
884
  ### @jgengine/shell/water
666
885
 
667
- - Ocean (function): function Ocean({ config, ...meshProps }: OceanProps): React.JSX.Element
668
- - OceanProps (interface): interface OceanProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
669
886
  - DEFAULT_OCEAN_CONFIG (const): const DEFAULT_OCEAN_CONFIG: ResolvedOceanConfig
887
+ - DEFAULT_OCEAN_WAVE_SCALE (const): const DEFAULT_OCEAN_WAVE_SCALE: 18 — Shared with `@jgengine/core/world/water` — primary wavelength in world units.
670
888
  - MAX_OCEAN_WAVES (const): const MAX_OCEAN_WAVES: 6
671
889
  - OCEAN_QUALITY_PRESETS (const): const OCEAN_QUALITY_PRESETS: Record<OceanQualityPreset, { size: number; resolution: number }>
672
- - buildOceanWaveUniforms (function): function buildOceanWaveUniforms(config: ResolvedOceanConfig): { directions: THREE.Vector2[]; params: THREE.Vector4[]; }
673
- - createOceanConfig (function): function createOceanConfig(patch: OceanConfig = {}): ResolvedOceanConfig
890
+ - Ocean (function): function Ocean({ config, ...meshProps }: OceanProps): React.JSX.Element
674
891
  - OceanColorConfig (interface): interface OceanColorConfig
675
892
  - OceanConfig (interface): interface OceanConfig
676
893
  - OceanDirectionVector (interface): interface OceanDirectionVector
677
894
  - OceanFoamConfig (interface): interface OceanFoamConfig
895
+ - OceanMaterialUniforms (interface): interface OceanMaterialUniforms
896
+ - OceanProps (interface): interface OceanProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
678
897
  - OceanQualityPreset (type): type OceanQualityPreset = "low" | "medium" | "high" | "ultra"
898
+ - OceanShaderMaterial (type): type OceanShaderMaterial = THREE.ShaderMaterial & { uniforms: OceanMaterialUniforms }
679
899
  - OceanWaveConfig (interface): interface OceanWaveConfig
680
900
  - OceanWaveDirection (type): type OceanWaveDirection = number | OceanDirectionVector
681
901
  - ResolvedOceanColorConfig (interface): interface ResolvedOceanColorConfig
682
902
  - ResolvedOceanConfig (interface): interface ResolvedOceanConfig
683
903
  - ResolvedOceanFoamConfig (interface): interface ResolvedOceanFoamConfig
684
904
  - ResolvedOceanWaveConfig (interface): interface ResolvedOceanWaveConfig
905
+ - buildOceanWaveUniforms (function): function buildOceanWaveUniforms(config: ResolvedOceanConfig): { directions: THREE.Vector2[]; params: THREE.Vector4[]; }
906
+ - createOceanConfig (function): function createOceanConfig(patch: OceanConfig = {}): ResolvedOceanConfig
685
907
  - createOceanMaterial (function): function createOceanMaterial(config: ResolvedOceanConfig): OceanShaderMaterial
686
908
  - syncOceanMaterial (function): function syncOceanMaterial(material: OceanShaderMaterial, config: ResolvedOceanConfig, elapsedSeconds: number): void
687
- - OceanMaterialUniforms (interface): interface OceanMaterialUniforms
688
- - OceanShaderMaterial (type): type OceanShaderMaterial = THREE.ShaderMaterial & { uniforms: OceanMaterialUniforms }
689
909
 
690
- ### @jgengine/shell/water/index
910
+ ### @jgengine/shell/water/Ocean
691
911
 
692
912
  - Ocean (function): function Ocean({ config, ...meshProps }: OceanProps): React.JSX.Element
693
913
  - OceanProps (interface): interface OceanProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
914
+
915
+ ### @jgengine/shell/water/OceanConfig
916
+
694
917
  - DEFAULT_OCEAN_CONFIG (const): const DEFAULT_OCEAN_CONFIG: ResolvedOceanConfig
918
+ - DEFAULT_OCEAN_WAVE_SCALE (const): const DEFAULT_OCEAN_WAVE_SCALE: 18 — Shared with `@jgengine/core/world/water` — primary wavelength in world units.
695
919
  - MAX_OCEAN_WAVES (const): const MAX_OCEAN_WAVES: 6
696
920
  - OCEAN_QUALITY_PRESETS (const): const OCEAN_QUALITY_PRESETS: Record<OceanQualityPreset, { size: number; resolution: number }>
697
- - buildOceanWaveUniforms (function): function buildOceanWaveUniforms(config: ResolvedOceanConfig): { directions: THREE.Vector2[]; params: THREE.Vector4[]; }
698
- - createOceanConfig (function): function createOceanConfig(patch: OceanConfig = {}): ResolvedOceanConfig
699
921
  - OceanColorConfig (interface): interface OceanColorConfig
700
922
  - OceanConfig (interface): interface OceanConfig
701
923
  - OceanDirectionVector (interface): interface OceanDirectionVector
@@ -707,51 +929,76 @@ Imports use deep paths: `@jgengine/shell/<path>`.
707
929
  - ResolvedOceanConfig (interface): interface ResolvedOceanConfig
708
930
  - ResolvedOceanFoamConfig (interface): interface ResolvedOceanFoamConfig
709
931
  - ResolvedOceanWaveConfig (interface): interface ResolvedOceanWaveConfig
710
- - createOceanMaterial (function): function createOceanMaterial(config: ResolvedOceanConfig): OceanShaderMaterial
711
- - syncOceanMaterial (function): function syncOceanMaterial(material: OceanShaderMaterial, config: ResolvedOceanConfig, elapsedSeconds: number): void
932
+ - buildOceanWaveUniforms (function): function buildOceanWaveUniforms(config: ResolvedOceanConfig): { directions: THREE.Vector2[]; params: THREE.Vector4[]; }
933
+ - createOceanConfig (function): function createOceanConfig(patch: OceanConfig = {}): ResolvedOceanConfig
934
+
935
+ ### @jgengine/shell/water/OceanMaterial
936
+
712
937
  - OceanMaterialUniforms (interface): interface OceanMaterialUniforms
713
938
  - OceanShaderMaterial (type): type OceanShaderMaterial = THREE.ShaderMaterial & { uniforms: OceanMaterialUniforms }
939
+ - createOceanMaterial (function): function createOceanMaterial(config: ResolvedOceanConfig): OceanShaderMaterial
940
+ - syncOceanMaterial (function): function syncOceanMaterial(material: OceanShaderMaterial, config: ResolvedOceanConfig, elapsedSeconds: number): void
714
941
 
715
- ### @jgengine/shell/water/Ocean
942
+ ### @jgengine/shell/water/OceanShader
716
943
 
717
- - Ocean (function): function Ocean({ config, ...meshProps }: OceanProps): React.JSX.Element
718
- - OceanProps (interface): interface OceanProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
944
+ - oceanFragmentShader (const): const oceanFragmentShader: "\nuniform vec3 uShallowColor;\nuniform vec3 uDeepColor;\nuniform vec3 uCrestColor;\nuniform vec3 uFoamColor;\nuniform float uOpacity;\nuniform float uFresnelStrength;\nuniform float uHorizonBlend;\nuniform float uFoamIntensity;\n\nvarying vec3 vWorldPosition;\nvarying vec…
945
+ - oceanVertexShader (const): const oceanVertexShader: "\nuniform float uTime;\nuniform vec2 uWaveDirections[6];\nuniform vec4 uWaveParams[6];\nuniform float uChoppiness;\nuniform float uFoamThreshold;\nuniform float uFoamSoftness;\nuniform float uFoamCoverage;\n\nvarying vec3 vWorldPosition;\nvarying vec3 vNormal;\nvarying floa…
719
946
 
720
- ### @jgengine/shell/water/OceanConfig
947
+ ### @jgengine/shell/water/index
721
948
 
722
- - createOceanConfig (function): function createOceanConfig(patch: OceanConfig = {}): ResolvedOceanConfig
723
- - buildOceanWaveUniforms (function): function buildOceanWaveUniforms(config: ResolvedOceanConfig): { directions: THREE.Vector2[]; params: THREE.Vector4[]; }
949
+ - DEFAULT_OCEAN_CONFIG (const): const DEFAULT_OCEAN_CONFIG: ResolvedOceanConfig
950
+ - DEFAULT_OCEAN_WAVE_SCALE (const): const DEFAULT_OCEAN_WAVE_SCALE: 18 — Shared with `@jgengine/core/world/water` — primary wavelength in world units.
724
951
  - MAX_OCEAN_WAVES (const): const MAX_OCEAN_WAVES: 6
725
- - OceanQualityPreset (type): type OceanQualityPreset = "low" | "medium" | "high" | "ultra"
726
- - OceanDirectionVector (interface): interface OceanDirectionVector
727
- - OceanWaveDirection (type): type OceanWaveDirection = number | OceanDirectionVector
728
- - OceanWaveConfig (interface): interface OceanWaveConfig
952
+ - OCEAN_QUALITY_PRESETS (const): const OCEAN_QUALITY_PRESETS: Record<OceanQualityPreset, { size: number; resolution: number }>
953
+ - Ocean (function): function Ocean({ config, ...meshProps }: OceanProps): React.JSX.Element
729
954
  - OceanColorConfig (interface): interface OceanColorConfig
730
- - OceanFoamConfig (interface): interface OceanFoamConfig
731
955
  - OceanConfig (interface): interface OceanConfig
956
+ - OceanDirectionVector (interface): interface OceanDirectionVector
957
+ - OceanFoamConfig (interface): interface OceanFoamConfig
958
+ - OceanMaterialUniforms (interface): interface OceanMaterialUniforms
959
+ - OceanProps (interface): interface OceanProps extends Omit<ThreeElements["mesh"], "args" | "children" | "geometry" | "material">
960
+ - OceanQualityPreset (type): type OceanQualityPreset = "low" | "medium" | "high" | "ultra"
961
+ - OceanShaderMaterial (type): type OceanShaderMaterial = THREE.ShaderMaterial & { uniforms: OceanMaterialUniforms }
962
+ - OceanWaveConfig (interface): interface OceanWaveConfig
963
+ - OceanWaveDirection (type): type OceanWaveDirection = number | OceanDirectionVector
732
964
  - ResolvedOceanColorConfig (interface): interface ResolvedOceanColorConfig
965
+ - ResolvedOceanConfig (interface): interface ResolvedOceanConfig
733
966
  - ResolvedOceanFoamConfig (interface): interface ResolvedOceanFoamConfig
734
967
  - ResolvedOceanWaveConfig (interface): interface ResolvedOceanWaveConfig
735
- - ResolvedOceanConfig (interface): interface ResolvedOceanConfig
736
- - OCEAN_QUALITY_PRESETS (const): const OCEAN_QUALITY_PRESETS: Record<OceanQualityPreset, { size: number; resolution: number }>
737
- - DEFAULT_OCEAN_CONFIG (const): const DEFAULT_OCEAN_CONFIG: ResolvedOceanConfig
738
-
739
- ### @jgengine/shell/water/OceanMaterial
740
-
968
+ - buildOceanWaveUniforms (function): function buildOceanWaveUniforms(config: ResolvedOceanConfig): { directions: THREE.Vector2[]; params: THREE.Vector4[]; }
969
+ - createOceanConfig (function): function createOceanConfig(patch: OceanConfig = {}): ResolvedOceanConfig
741
970
  - createOceanMaterial (function): function createOceanMaterial(config: ResolvedOceanConfig): OceanShaderMaterial
742
971
  - syncOceanMaterial (function): function syncOceanMaterial(material: OceanShaderMaterial, config: ResolvedOceanConfig, elapsedSeconds: number): void
743
- - OceanMaterialUniforms (interface): interface OceanMaterialUniforms
744
- - OceanShaderMaterial (type): type OceanShaderMaterial = THREE.ShaderMaterial & { uniforms: OceanMaterialUniforms }
745
972
 
746
- ### @jgengine/shell/water/OceanShader
973
+ ### @jgengine/shell/weather/FireSpreadLayer
974
+
975
+ - FireSpreadLayer (function): function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, flameHeight = 1.6, burningColor = "#ff6a1a", emberColor = "#4a1206", }: FireSpreadLayerProps): React.JSX.Element
976
+ - FireSpreadLayerProps (interface): interface FireSpreadLayerProps
977
+
978
+ ### @jgengine/shell/weather/LightningStrike
979
+
980
+ - LightningStrike (function): function LightningStrike({ origin, target, strikeKey = 0, seed = 451, visible = true, duration = 0.18, color = DEFAULT_COLOR, glow = 2.4, branches = 5, jaggedness = 0.08, impactLight = 26, renderOrder = 20, }: LightningStrikeProps): React.JSX.Element
981
+ - LightningStrikeProps (interface): interface LightningStrikeProps
982
+
983
+ ### @jgengine/shell/weather/RainField
984
+
985
+ - RainField (function): function RainField({ count = DEFAULT_RAIN_COUNT, density = DEFAULT_RAIN_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 22, length = 1.35, width = 0.018, opacity = 0.48, color = DEFAULT_RAIN_COLOR, lightning, timeScale, seed = 11939, renderOrder …
986
+ - RainFieldProps (interface): interface RainFieldProps
987
+
988
+ ### @jgengine/shell/weather/SnowField
989
+
990
+ - SnowField (function): function SnowField({ count = DEFAULT_SNOW_COUNT, density = DEFAULT_SNOW_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, frustumC…
991
+ - SnowFieldProps (interface): interface SnowFieldProps
992
+
993
+ ### @jgengine/shell/weather/WeatherLayer
747
994
 
748
- - oceanVertexShader (const): const oceanVertexShader: "\nuniform float uTime;\nuniform vec2 uWaveDirections[6];\nuniform vec4 uWaveParams[6];\nuniform float uChoppiness;\nuniform float uFoamThreshold;\nuniform float uFoamSoftness;\nuniform float uFoamCoverage;\n\nvarying vec3 vWorldPosition;\nvarying vec3 vNormal;\nvarying floa…
749
- - oceanFragmentShader (const): const oceanFragmentShader: "\nuniform vec3 uShallowColor;\nuniform vec3 uDeepColor;\nuniform vec3 uCrestColor;\nuniform vec3 uFoamColor;\nuniform float uOpacity;\nuniform float uFresnelStrength;\nuniform float uHorizonBlend;\nuniform float uFoamIntensity;\n\nvarying vec3 vWorldPosition;\nvarying vec…
995
+ - WeatherLayer (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null
996
+ - WeatherLayerMode (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed"
997
+ - WeatherLayerProps (interface): interface WeatherLayerProps
750
998
 
751
- ### @jgengine/shell/weather/FireSpreadLayer
999
+ ### @jgengine/shell/weather/fireSpreadPose
752
1000
 
753
- - FireSpreadLayer (function): function FireSpreadLayer({ grid, cellSize, origin = [0, 0], heightAt, flameHeight = 1.6, burningColor = "#ff6a1a", emberColor = "#4a1206", }: FireSpreadLayerProps): React.JSX.Element
754
- - FireSpreadLayerProps (interface): interface FireSpreadLayerProps
1001
+ - setBillboardQuaternion (function): function setBillboardQuaternion(quaternion: Quaternion, euler: Euler, yaw: number): void
755
1002
 
756
1003
  ### @jgengine/shell/weather/index
757
1004
 
@@ -759,107 +1006,162 @@ Imports use deep paths: `@jgengine/shell/<path>`.
759
1006
  - FireSpreadLayerProps (interface): interface FireSpreadLayerProps
760
1007
  - LightningStrike (function): function LightningStrike({ origin, target, strikeKey = 0, seed = 451, visible = true, duration = 0.18, color = DEFAULT_COLOR, glow = 2.4, branches = 5, jaggedness = 0.08, impactLight = 26, renderOrder = 20, }: LightningStrikeProps): React.JSX.Element
761
1008
  - LightningStrikeProps (interface): interface LightningStrikeProps
762
- - RainField (function): function RainField({ count = 8000, density = 0.45, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 22, length = 1.35, width = 0.018, opacity = 0.48, color = DEFAULT_RAIN_COLOR, lightning, timeScale, seed = 11939, renderOrder = 10, }: RainFieldProps): React.JSX.El
1009
+ - RainField (function): function RainField({ count = DEFAULT_RAIN_COUNT, density = DEFAULT_RAIN_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 22, length = 1.35, width = 0.018, opacity = 0.48, color = DEFAULT_RAIN_COLOR, lightning, timeScale, seed = 11939, renderOrder …
763
1010
  - RainFieldProps (interface): interface RainFieldProps
764
- - SnowField (function): function SnowField({ count = 6000, density = 0.5, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, }: SnowFieldProps): React.JSX.Element
1011
+ - SnowField (function): function SnowField({ count = DEFAULT_SNOW_COUNT, density = DEFAULT_SNOW_DENSITY, budget, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, frustumC…
765
1012
  - SnowFieldProps (interface): interface SnowFieldProps
766
1013
  - WeatherLayer (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null
767
1014
  - WeatherLayerMode (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed"
768
1015
  - WeatherLayerProps (interface): interface WeatherLayerProps
769
- - createWeatherUniformSet (function): function createWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
770
- - WeatherUniformProvider (function): function WeatherUniformProvider({ children, ...options }: WeatherUniformOptions & { children: ReactNode }): React.JSX.Element
771
- - useWeatherUniformSet (function): function useWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
772
1016
  - WeatherUniformOptions (interface): interface WeatherUniformOptions
1017
+ - WeatherUniformProvider (function): function WeatherUniformProvider({ children, ...options }: WeatherUniformOptions & { children: ReactNode }): React.JSX.Element
773
1018
  - WeatherUniformSet (interface): interface WeatherUniformSet
774
1019
  - WeatherVector (type): type WeatherVector = readonly [number, number, number]
775
-
776
- ### @jgengine/shell/weather/LightningStrike
777
-
778
- - LightningStrike (function): function LightningStrike({ origin, target, strikeKey = 0, seed = 451, visible = true, duration = 0.18, color = DEFAULT_COLOR, glow = 2.4, branches = 5, jaggedness = 0.08, impactLight = 26, renderOrder = 20, }: LightningStrikeProps): React.JSX.Element
779
- - LightningStrikeProps (interface): interface LightningStrikeProps
780
-
781
- ### @jgengine/shell/weather/RainField
782
-
783
- - RainField (function): function RainField({ count = 8000, density = 0.45, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 22, length = 1.35, width = 0.018, opacity = 0.48, color = DEFAULT_RAIN_COLOR, lightning, timeScale, seed = 11939, renderOrder = 10, }: RainFieldProps): React.JSX.El…
784
- - RainFieldProps (interface): interface RainFieldProps
785
-
786
- ### @jgengine/shell/weather/SnowField
787
-
788
- - SnowField (function): function SnowField({ count = 6000, density = 0.5, volume = DEFAULT_VOLUME, wind, origin = DEFAULT_ORIGIN, followCamera = true, speed = 3.2, size = 0.11, sway = 0.62, opacity = 0.86, color = DEFAULT_SNOW_COLOR, timeScale, seed = 72931, renderOrder = 11, }: SnowFieldProps): React.JSX.Element
789
- - SnowFieldProps (interface): interface SnowFieldProps
1020
+ - createWeatherUniformSet (function): function createWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
1021
+ - useWeatherUniformSet (function): function useWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
790
1022
 
791
1023
  ### @jgengine/shell/weather/weatherGeometry
792
1024
 
793
1025
  - createWeatherQuadGeometry (function): function createWeatherQuadGeometry(maxCount: number, seed: number): THREE.InstancedBufferGeometry
794
1026
 
795
- ### @jgengine/shell/weather/WeatherLayer
796
-
797
- - WeatherLayer (function): function WeatherLayer({ mode = "clear", intensity = 1, wind, lightning, timeScale, rain, snow, enabled = true, children, }: WeatherLayerProps): React.JSX.Element | null
798
- - WeatherLayerMode (type): type WeatherLayerMode = "clear" | "rain" | "snow" | "mixed"
799
- - WeatherLayerProps (interface): interface WeatherLayerProps
800
-
801
1027
  ### @jgengine/shell/weather/weatherMath
802
1028
 
1029
+ - DEFAULT_RAIN_COUNT (const): const DEFAULT_RAIN_COUNT: 2000
1030
+ - DEFAULT_RAIN_DENSITY (const): const DEFAULT_RAIN_DENSITY: 0.45
1031
+ - DEFAULT_SNOW_COUNT (const): const DEFAULT_SNOW_COUNT: 1500
1032
+ - DEFAULT_SNOW_DENSITY (const): const DEFAULT_SNOW_DENSITY: 0.5
1033
+ - WeatherSeedAttributes (interface): interface WeatherSeedAttributes
803
1034
  - clampWeatherRatio (function): function clampWeatherRatio(value: number): number
804
- - resolveWeatherInstanceCount (function): function resolveWeatherInstanceCount(maxCount: number, density: number): number
805
1035
  - createWeatherSeedAttributes (function): function createWeatherSeedAttributes(maxCount: number, seed: number): WeatherSeedAttributes
806
- - WeatherSeedAttributes (interface): interface WeatherSeedAttributes
1036
+ - resolveWeatherInstanceCount (function): function resolveWeatherInstanceCount(maxCount: number, density: number, budget?: number): number
807
1037
 
808
1038
  ### @jgengine/shell/weather/weatherUniforms
809
1039
 
810
- - createWeatherUniformSet (function): function createWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
1040
+ - WeatherUniformOptions (interface): interface WeatherUniformOptions
811
1041
  - WeatherUniformProvider (function): function WeatherUniformProvider({ children, ...options }: WeatherUniformOptions & { children: ReactNode }): React.JSX.Element
812
- - useWeatherUniformSet (function): function useWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
813
- - WeatherVector (type): type WeatherVector = readonly [number, number, number]
814
1042
  - WeatherUniformSet (interface): interface WeatherUniformSet
815
- - WeatherUniformOptions (interface): interface WeatherUniformOptions
1043
+ - WeatherVector (type): type WeatherVector = readonly [number, number, number]
1044
+ - createWeatherUniformSet (function): function createWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
1045
+ - useWeatherUniformSet (function): function useWeatherUniformSet(options: WeatherUniformOptions = {}): WeatherUniformSet
816
1046
 
817
1047
  ### @jgengine/shell/world/DataObjects
818
1048
 
819
- - DataObjects (function): function DataObjects<T>({ data, position, height, color, cellSize = 0.28, hovered = null, hoverColor = "#ffffff", onHover, grow, castShadow = true, receiveShadow = true, renderItem, }: DataObjectsProps<T>): React.JSX.Element
820
- - DataObjectsProps (interface): interface DataObjectsProps<T> Renders one placed 3D object per data item — a 3D bar chart, a heatmap, a city of buildings, a crowd. By default each item is an extruded box (all boxes share one `InstancedMesh`, so hundreds cost one draw call), sized and colored from the item; `renderItem` swaps the box for arbitrary content (a sprite, a GLB, a full entity). Genre-agnostic: the caller owns the data `T`, this owns the placement.
821
-
822
- ### @jgengine/shell/world/floatTextStyle
823
-
824
- - resolveFloatTextStyle (function): function resolveFloatTextStyle(info: FloatTextInfo): FloatTextStyle
825
- - FloatTextInfo (interface): interface FloatTextInfo
826
- - FloatTextStyle (interface): interface FloatTextStyle
1049
+ - DataObjects (function): function DataObjects<T>({ data, position, height, color, cellSize = 0.28, hovered = null, hoverColor = "#ffffff", onHover, grow, castShadow = true, receiveShadow = true, renderItem, }: DataObjectsProps<T>): React.JSX.Element | null
1050
+ - DataObjectsProps (interface): interface DataObjectsProps<T> — Renders one placed 3D object per data item — a 3D bar chart, a heatmap, a city of buildings, a crowd. By default each item is an extruded box (all boxes share one `InstancedMesh`, so hundreds cost one draw call), sized and colored from the item; `renderItem` swaps the box for arbitrary content (a sprite, a GLB, a full entity). Genre-agnostic: the caller owns the data `T`, this owns the placement.
827
1051
 
828
1052
  ### @jgengine/shell/world/GridWorldScene
829
1053
 
830
- - GridWorldScene (function): function GridWorldScene({ feature }: GridWorldSceneProps): React.JSX.Element | null Data-driven renderer for the `biomes()`/`voxel()`/`plots()`/`tilemap()` world-feature kinds (#207.1): one `InstancedMesh` of extruded, colored boxes built from each feature's declared `cells`, following the same direct-buffer pattern as `InstancedBodies`.
1054
+ - GridWorldScene (function): function GridWorldScene({ feature }: GridWorldSceneProps): React.JSX.Element | null — Data-driven renderer for the `biomes()`/`voxel()`/`plots()`/`tilemap()` world-feature kinds (#207.1): one `InstancedMesh` of extruded, colored boxes built from each feature's declared `cells`, following the same direct-buffer pattern as `InstancedBodies`.
831
1055
  - GridWorldSceneProps (interface): interface GridWorldSceneProps
832
1056
 
833
1057
  ### @jgengine/shell/world/InstancedBodies
834
1058
 
835
- - InstancedBodies (function): function InstancedBodies({ world, debugTint = false, baseColors, epoch = 0 }: InstancedBodiesProps): React.JSX.Element Renders a PhysicsWorld's box bodies as a single InstancedMesh — one draw call per batch. Transforms are written directly into `instanceMatrix.array` each frame (bodies never touch the per-entity React path). Reusable by any game: hand it a physics world and go.
1059
+ - InstancedBodies (function): function InstancedBodies({ world, debugTint = false, baseColors, epoch = 0 }: InstancedBodiesProps): React.JSX.Element — Renders a PhysicsWorld's box bodies as a single InstancedMesh — one draw call per batch. Transforms are written directly into `instanceMatrix.array` each frame (bodies never touch the per-entity React path). Reusable by any game: hand it a physics world and go.
836
1060
  - InstancedBodiesProps (interface): interface InstancedBodiesProps
837
1061
 
838
1062
  ### @jgengine/shell/world/InstancedJoints
839
1063
 
840
- - InstancedJoints (function): function InstancedJoints({ world, color = "#f5c542" }: InstancedJointsProps): React.JSX.Element Debug overlay drawing a PhysicsWorld's joints (suspension, ragdoll links, carry tethers) as one LineSegments batch. Endpoints are streamed each frame from `world.readJointSegments`; pair with `InstancedBodies` to see the constraint structure over the bodies.
1064
+ - InstancedJoints (function): function InstancedJoints({ world, color = "#f5c542" }: InstancedJointsProps): React.JSX.Element — Debug overlay drawing a PhysicsWorld's joints (suspension, ragdoll links, carry tethers) as one LineSegments batch. Endpoints are streamed each frame from `world.readJointSegments`; pair with `InstancedBodies` to see the constraint structure over the bodies.
841
1065
  - InstancedJointsProps (interface): interface InstancedJointsProps
842
1066
 
1067
+ ### @jgengine/shell/world/SpriteBatch
1068
+
1069
+ - SpriteBatch (function): function SpriteBatch({ url, columns = 1, rows = 1, capacity = DEFAULT_CAPACITY, instances, plane = "xy", billboard = false, pixelated = true, alphaTest = DEFAULT_ALPHA_TEST, opacity = 1, }: SpriteBatchProps): React.JSX.Element — Renders a sprite sheet / tile atlas as a single InstancedMesh — one draw call for the whole batch. Each instance picks its atlas frame via a per-instance UV offset attribute patched into the material's vertex shader, so platformer/puzzle-grid presentation never needs one draw call per sprite. Transforms and UV offsets are written directly into the mesh's typed arrays each frame from a plain instance list (bodies never touch the per-entity React path).
1070
+ - SpriteBatchInstance (interface): interface SpriteBatchInstance
1071
+ - SpriteBatchProps (interface): interface SpriteBatchProps
1072
+
843
1073
  ### @jgengine/shell/world/WorldHud
844
1074
 
845
- - WorldEntityBars (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; }): React.JSX.Element
846
- - WorldFloatText (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element
847
- - WorldTelegraphs (function): function WorldTelegraphs(): React.JSX.Element
848
1075
  - CombatCameraShake (function): function CombatCameraShake(): null
849
1076
  - ProjectileTracers (function): function ProjectileTracers({ lifeMs = 130 }: { lifeMs?: number }): React.JSX.Element
850
1077
  - Reticle (function): function Reticle({ className }: { className?: string }): React.JSX.Element
1078
+ - WorldBarSample (interface): interface WorldBarSample
1079
+ - WorldEntityBars (function): function WorldEntityBars({ statId, height = 2.2, roles, resolveRole, }: { statId: string; height?: number; roles?: readonly CatalogEntityRole[]; resolveRole?: (entity: SceneEntity) => CatalogEntityRole | undefined; }): React.JSX.Element
1080
+ - WorldFloatText (function): function WorldFloatText({ height = 1.9, lifeMs = 950 }: { height?: number; lifeMs?: number }): React.JSX.Element
1081
+ - WorldTelegraphs (function): function WorldTelegraphs(): React.JSX.Element
1082
+ - collectWorldBarSamples (function): function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: { matrixWorldInverse: unknown; projectionMatrix: unknown }, viewport: { width:…
1083
+ - paintWorldBarSamples (function): function paintWorldBarSamples(canvas: { width: number; height: number; getContext(kind: "2d"): CanvasRenderingContext2D | null }, samples: readonly WorldBarSample[], dpr: number, barWidthPx = 112, barHeightPx = 10): void
1084
+ - telegraphPulseOpacity (function): function telegraphPulseOpacity(bornMs: number, windupMs: number, nowMs: number): number
851
1085
 
852
1086
  ### @jgengine/shell/world/WorldItems
853
1087
 
854
- - WorldItems (function): function WorldItems({ config }: { config?: WorldItemRenderConfig }): React.JSX.Element Rarity→beam/color/label render binding + loot-filter overlay (#32/#33) for every dropped `worldItem`.
1088
+ - WorldItems (function): function WorldItems({ config }: { config?: WorldItemRenderConfig }): React.JSX.Element — Rarity→beam/color/label render binding + loot-filter overlay (#32/#33) for every dropped `worldItem`.
1089
+
1090
+ ### @jgengine/shell/world/entityPose
1091
+
1092
+ - PoseSource (interface): interface PoseSource
1093
+ - PoseWritable (interface): interface PoseWritable
1094
+ - posesEqual (function): function posesEqual(a: PoseSource, b: PoseSource): boolean
1095
+ - writeEntityPose (function): function writeEntityPose(target: PoseWritable, source: PoseSource): void
1096
+
1097
+ ### @jgengine/shell/world/floatTextStyle
1098
+
1099
+ - FloatTextInfo (interface): interface FloatTextInfo
1100
+ - FloatTextStyle (interface): interface FloatTextStyle
1101
+ - resolveFloatTextStyle (function): function resolveFloatTextStyle(info: FloatTextInfo): FloatTextStyle
1102
+
1103
+ ### @jgengine/shell/world/telegraphPulse
1104
+
1105
+ - telegraphPulseOpacity (function): function telegraphPulseOpacity(bornMs: number, windupMs: number, nowMs: number): number
1106
+
1107
+ ### @jgengine/shell/world/worldBarSamples
1108
+
1109
+ - Projectable (interface): interface Projectable
1110
+ - WorldBarSample (interface): interface WorldBarSample
1111
+ - collectWorldBarSamples (function): function collectWorldBarSamples(ctx: GameContext, statId: string, height: number, roles: readonly CatalogEntityRole[] | undefined, resolveRole: ((entity: SceneEntity) => CatalogEntityRole | undefined) | undefined, camera: { matrixWorldInverse: unknown; projectionMatrix: unknown }, viewport: { width:…
1112
+ - paintWorldBarSamples (function): function paintWorldBarSamples(canvas: { width: number; height: number; getContext(kind: "2d"): CanvasRenderingContext2D | null }, samples: readonly WorldBarSample[], dpr: number, barWidthPx = 112, barHeightPx = 10): void
855
1113
 
856
1114
  ## Guides
857
1115
 
858
- # JGengine — API Reference
1116
+ # JGengine
1117
+
1118
+ JGengine is a pure-TypeScript game-engine SDK for games built by AI coding agents. Its skills teach the agent how to use the SDK. When a user says “make X with JGengine” or “make X with jgengine.com,” treat that as sufficient intake. If the skills are not installed, run `npx jgengine skills`; then use this skill and build on the SDK. Do not make the user discover or name skills.
1119
+
1120
+ ## Intake
1121
+
1122
+ State the reading as a short numbered list that is easy to correct, then proceed unless the user changes it. Prefer concrete values and actions over prose:
1123
+
1124
+ 1. **POV:** first-person
1125
+ 2. **World:** custom 3D wasteland with three settlements
1126
+ 3. **Core loop:** get quests by talking to people → defeat enemies → return to redeem quests
1127
+ 4. **Interaction:** collect ground items by walking over them; interact with people and doors at close range
1128
+ 5. **Combat:** ranged weapons, damage, death, loot
1129
+ 6. **Progression:** inventory, currency, quest rewards, upgrades
1130
+ 7. **Players:** single-player, or name the multiplayer topology and synchronized systems
1131
+ 8. **UI:** visible controls, objective tracker, health, inventory feedback
1132
+ 9. **Art direction:** one aesthetic, palette, asset family, and UI voice
1133
+ 10. **Done looks like:** one observable end-to-end play scenario
1134
+
1135
+ Keep this compact—roughly one line per item. It is a build map, not a large specification or an approval gate. Infer conventional details from the named game or genre. Ask only when two plausible readings would fundamentally change the game.
1136
+
1137
+ ## Route selectively
1138
+
1139
+ This skill is the foundation for every task (packages, project shape, defineGame, context, catalogs). After intake, also read only the domain skills the work needs:
1140
+
1141
+ | Need | Read |
1142
+ | --- | --- |
1143
+ | Terrain, scenes, camera, movement, physics, maps, sensors | `jgengine-world` |
1144
+ | Seeded generation, terrain/environment generation, grids, buildings, simulation | `jgengine-procedural` |
1145
+ | Damage, effects, weapons, targeting, projectiles, loot, death | `jgengine-combat` |
1146
+ | Items, quests, dialogue, economy, crafting, objectives, turns, social systems | `jgengine-gameplay` |
1147
+ | Networking adapters, authority, rooms/topology, persistence/backend seams | `jgengine-multiplayer` |
1148
+ | React HUD, shell affordances, controls, feedback, accessibility | `jgengine-ui` |
1149
+ | Models, sprites, textures, audio, catalogs, attribution | `jgengine-assets` |
1150
+ | Proof and screenshots | `jgengine-verify` after implementation |
1151
+
1152
+ Do not read every domain by default. Build through documented engine surfaces; do not infer APIs from gallery games. Inspect engine source only when a documented surface appears wrong or a missing primitive blocks the work.
1153
+
1154
+ ## Build behavior
1155
+
1156
+ Scaffold with `npx jgengine create game-name --name "Game Name"` when needed. Build the requested game continuously from the intake, keeping systems end-to-end rather than leaving registered-but-unusable pieces. Use real assets and visible feedback early. Verify at completion with `jgengine-verify`.
859
1157
 
860
- The engine ships **verbs and primitives**; your game ships **nouns** (catalogs) and thin handlers. Read this before writing `game.config.ts` or any game content. Companion skills: **`jgengine-newgame`** (master blueprint + phased build to completion) and **`jgengine-verify`** (browserless scene gate) — read them before building. The UI quality bar lives in [`reference/ui-react.md`](reference/ui-react.md); asset sourcing lives in the **Assets** section below.
1158
+ Cartridge-shaped games (declarative config, engine-owned loop): see [reference-cartridge.md](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine/reference-cartridge.md).
861
1159
 
862
- The heaviest domains live in on-demand reference modules under [`reference/`](reference/) — load one only when you're building in that domain: [`reference/combat.md`](reference/combat.md) (effects, projectiles, death, feel, abilities), [`reference/world.md`](reference/world.md) (terrain, environment, physics, vehicles, spawn), [`reference/multiplayer.md`](reference/multiplayer.md) (transport, host, persistence, presence), [`reference/ui-react.md`](reference/ui-react.md) (`@jgengine/react` hooks, headless + styled UI kits, the registry install path, and the UI quality bar). Each domain's section below is a one-line pointer to its module.
1160
+ ---
1161
+
1162
+ The engine ships **verbs and primitives**; your game ships **nouns** (catalogs) and thin handlers. This skill is that foundation plus intake. Use domain skills only when needed; use `jgengine-verify` afterward. UI guidance lives in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md); assets live in `jgengine-assets`.
1163
+
1164
+ Load detailed references only for the selected domain: [`jgengine-combat`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-combat/reference.md), [`jgengine-world`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-world/reference.md), [`jgengine-multiplayer`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-multiplayer/reference.md), and [`jgengine-ui`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md). Each domain skill explains when its reference is needed.
863
1165
 
864
1166
  ## Packages
865
1167
 
@@ -893,7 +1195,7 @@ Exact import paths and export names — **do not invent paths**; every row below
893
1195
  |---------|----------------------------------|-----------|
894
1196
  | Game boot | `game/defineGame` | `defineGame`, `GameDefinition`, `GameLoop`, `InventoryDeclaration`, `PhysicsConfig`, `GameServerConfig`, `TimeConfig` |
895
1197
  | Simulation clock | `time/simClock` | `createSimClock`, `SimClock`, `TimeConfig`, `ClockSnapshot`, `CalendarTime` |
896
- | Runner contract | `game/playableGame` | `PlayableGame`, `GameCameraConfig`, `CameraRigKind`, `TopDownCameraConfig`, `RtsCameraConfig`, `ShoulderCameraConfig`, `LockOnCameraConfig`, `ChaseCameraConfig`, `ObserverCameraConfig`, `CameraShakeConfig`, `CinematicCameraConfig`, `CameraKeyframe`, `EntitySpriteConfig` |
1198
+ | Runner contract | `game/playableGame` | `PlayableGame`, `GameCameraConfig`, `CameraRigKind`, `CameraProjection`, `SideScrollCameraConfig`, `TopDownCameraConfig`, `RtsCameraConfig`, `ShoulderCameraConfig`, `LockOnCameraConfig`, `ChaseCameraConfig`, `ObserverCameraConfig`, `CameraShakeConfig`, `CinematicCameraConfig`, `CameraKeyframe`, `EntitySpriteConfig` |
897
1199
  | Runtime ctx | `runtime/gameContext` | `createGameContext`, `GameContext`, `GameContextContent`, `GameContextItemEntry`, `GameContextEntityEntry`, `GameContextObjectEntry`, `CatalogEntityRole` |
898
1200
  | Behaviour lifecycle | `behaviour/behaviour` | `Behaviour` (`onAwake`→`onEnable`→`onStart`→`onUpdate(dt)`→`onDisable`→`onDestroy`), `BehaviourModule`, `createBehaviourWorld`, `BehaviourWorld`, `JGEngineRegister`, `RegisterField`, `BehaviourModules` — Unity-style lifecycle over an id-keyed node tree (`setActive` cascade, lazy update dispatch); key nodes by entity instance ids. Games augment `JGEngineRegister` via `declare module "@jgengine/core/behaviour/behaviour"` for typed `world.modules`. Three.js binding: `Object3DBehaviour`, `attachObject3D`, `useBehaviourWorld` from `@jgengine/shell/behaviour` |
899
1201
  | Reactive keyed store | `store/observableKeyedStore` | `createObservableKeyedStore`, `ObservableKeyedStore` — backs `ctx.game.store` |
@@ -913,6 +1215,7 @@ Exact import paths and export names — **do not invent paths**; every row below
913
1215
  | Voxel field | `world/voxelField` | `createVoxelField`, `VoxelField`, `VoxelCell`, `VoxelHit`, `VoxelBounds`, `VoxelFieldSummary`, `VoxelFace`, `VOXEL_FACES`, `VOXEL_FACE_NORMALS` — a chunked block lattice, distinct from the `voxel()` `WorldFeature` descriptor |
914
1216
  | Terrain field | `world/terrain` | `TerrainField`, `noiseField`, `resolveTerrainField`, `rollingField`, `fractalNoise`, `valueNoise`, `withNormal`, `arenaField`, `flatField`, `resolveGroundStep`, `snapToGround`, `snapEntityToGround`, `resolveTerrainPalette`, `TERRAIN_MATERIAL_PALETTES` |
915
1217
  | Seeded RNG | `random/rng` | `seededRng`, `seededStreams` |
1218
+ | Seed share link | `random/seedLink` | `withSeedParam`, `seedFromUrl`, `seedFromSearch`, `dailySeed`, `DEFAULT_SEED_PARAM` — encode/decode a world seed to/from a shareable URL query param; `dailySeed` is the UTC daily-run seed |
916
1219
  | Name generator | `random/nameGen` | `createNameGenerator`, `pickFrom`, `fillTemplate`, `NameGenerator`, `NameGeneratorOptions`, `SyllableBank` |
917
1220
  | Regions | `world/regions` | `createRegionField`, `isRegionField`, `RegionDef`, `RegionField`, `RegionSample` |
918
1221
  | Wind field | `world/wind` | `windField`, `WindField`, `WindFieldConfig`, `WindVector` |
@@ -943,6 +1246,7 @@ Exact import paths and export names — **do not invent paths**; every row below
943
1246
  | Persistence scopes | `runtime/persistenceScope` | `partitionScopes`, `resetRun`, `mergeScopes`, `clearRunFields`, `applyRunReset`, `planScenarioReset`, `ScopeSchema`, `ScenarioReset`, `PersistenceScope` |
944
1247
  | Inventory | `inventory/inventoryModel` | `InventoryLayout`, `InventorySet`, `ItemTraits` |
945
1248
  | Progression | `game/progression` | `curve`, `evalCurve`, `leveling`, `Curve`, `LevelingTrack`, `LevelProgress` |
1249
+ | Talent tree | `game/talents` | `createTalentTree`, `TalentTree`, `TalentTreeConfig`, `TalentNodeDef`, `TalentRequirement`, `TalentAllocateResult`, `ResolvedTalents`, `TalentSnapshot` — point spends gated by prereqs + points-in-branch, resolved once into a cached flat `StatModifierSet` + ability grants |
946
1250
  | Inventory slots | `inventory/slotModel` | `createSlots`, `placeAt`, `removeAt`, `moveSlot`, `firstEmpty`, `compactSlots`, `Slot`, `SlotGrid` |
947
1251
  | Shaped inventory | `inventory/shapedGrid` | `createShapedGrid`, `placeShaped`, `moveShaped`, `removeShaped`, `canPlace`, `rotateFootprint`, `occupiedCells`, `gridAdjacencyQuery`, `cellFromPoint`, `ShapedGrid`, `Footprint`, `Placement`, `Rotation` |
948
1252
  | Card piles | `cards/cardPile` | `createCardPile`, `createCardPileState`, `draw`, `moveCards`, `shuffleZone`, `pileRng`, `CardPile`, `CardPileState`, `CardPileConfig` |
@@ -960,6 +1264,7 @@ Exact import paths and export names — **do not invent paths**; every row below
960
1264
  | Build permissions | `world/buildPermissions` | `createPlotPermissions`, `createContributionPool`, `PlotPermissions`, `ContributionPool`, `BuildRole`, `ContributionGoal` |
961
1265
  | Interiors | `world/interiors` | `createInteriors`, `Interior`, `Exterior`, `SpaceRef` |
962
1266
  | Game clock | `time/gameClock` | `getScaledElapsedMs`, `computeGameDay`, `SECONDS_PER_GAME_DAY` |
1267
+ | Idle / offline catch-up | `time/idleProgress` | `idleWindow`, `linearCatchUp`, `exponentialCatchUp`, `steppedCatchUp`, `IdleWindow`, `IdleWindowConfig`, `SteppedCatchUpResult` — elapsed-real-time production/growth/decay for a game reopened after being closed |
963
1268
  | Scene behaviors | `scene/behaviors` | `wander`, `patrol`, `promptable`, `talkable`, `player` |
964
1269
  | Capture check | `scene/captureCheck` | `captureChance`, `rollCapture`, `CaptureCheckInput` |
965
1270
  | Owned roster | `scene/roster` | `createRoster`, `Roster`, `RosterEntry`, `RosterCaptureOptions` |
@@ -988,6 +1293,7 @@ Exact import paths and export names — **do not invent paths**; every row below
988
1293
  | JSON data source | `data/jsonDataSource` | `createJsonDataSource`, `JsonDataSourceOptions` |
989
1294
  | Dev proxy routing | `data/devProxy` | `proxiedUrl`, `parseDevProxyTable`, `DevProxyTable`, `ProxiedUrlOptions`, `DEFAULT_DEV_PROXY_PREFIX` |
990
1295
  | Grid-cell world rendering | `world/gridInstances` | `resolveGridInstances`, `GridInstanceTransform` |
1296
+ | Swarm LOD scheduler | `world/lod` | `createLodScheduler`, `LodScheduler`, `LodSchedulerConfig`, `LodBand` — distance→band index for render detail, `step(id, distance, dt)` throttles per-entity updates by band interval (staggered, accumulates skipped time); pairs with `@jgengine/shell/world/SpriteBatch` for 1000+ entity swarms |
991
1297
  | Turn loop | `turn/turnLoop` | `createTurnLoop`, `TurnLoop`, `TurnLoopConfig`, `TurnState`, `PoolConfig`, `PoolState`, `TurnLoopSnapshot` |
992
1298
  | Declared-action intent board | `turn/intent` | `createIntentBoard`, `IntentBoard`, `DeclaredIntent` — `declare(participantId, intent)`, `peek`, `all`, `consume`, `clear` |
993
1299
  | Commit modes | `turn/commit` | `createCommitController`, `CommitController`, `CommitMode`, `CommitOutcome`, `SubmittedAction` |
@@ -1007,8 +1313,10 @@ Exact import paths and export names — **do not invent paths**; every row below
1007
1313
  | Beat clock | `time/beatClock` | `createBeatClock`, `createBeatInputBuffer`, `nextBeatTime`, `BeatClock`, `BeatClockConfig`, `BeatSnapshot`, `BeatInputBuffer`, `BufferedAction` |
1008
1314
  | Spawn director | `ai/spawnDirector` | `createSpawnDirectorState`, `advanceSpawnDirector`, `advanceWave`, `raiseAlert`, `pickSpawnPoint`, `SpawnDirectorConfig`, `WaveManifest`, `SpawnEntry`, `SpawnRequest`, `DirectorContext` |
1009
1315
  | Threat table | `ai/threat` | `createThreatTable`, `ThreatTable`, `ThreatTableConfig`, `ThreatEntry`, `HighestThreatOptions` |
1316
+ | Group-assist aggro | `ai/groupAssist` | `createAssistNetwork`, `AssistNetwork`, `AssistNetworkConfig`, `AssistMember` — propagates one member's threat gains to same-group members (optional radius + `distanceBetween` gating) so a single pull rallies the group |
1010
1317
  | Job board | `ai/jobBoard` | `createJobBoard`, `JobBoard`, `JobDef`, `Job`, `JobPhase`, `WorkerState`, `JobReport`, `JobTickContext` |
1011
1318
  | Crowd flow | `ai/crowd` | `computeFlowField`, `createCrowdField`, `selectPoi`, `FlowField`, `FlowFieldOptions`, `CrowdField`, `Poi`, `SelectPoiOptions` |
1319
+ | Factions & reputation | `faction/factions`, `faction/reputation` | `createFactionGraph`, `createFactionRoster`, `FactionRelation`, `FactionDef`, `FactionGraph`, `FactionRoster`, `createReputationLedger`, `DEFAULT_REPUTATION_TIERS`, `tierForStanding`, `effectiveRelation`, `ReputationTier`, `ReputationLedger` |
1012
1320
  | Physics actors | `physics/ragdoll`, `physics/carryable`, `physics/forceVolume`, `physics/spatialGrid` | `createRagdoll`, `Ragdoll`, `Carryable`, `carrySpeedMultiplier`, `ForceVolume`, `PlatformCarry`, `SpatialGrid` |
1013
1321
  | Traversal (grapple/glide) | `physics/traversal` | `Grapple`, `GrappleConfig`, `Glide`, `GlideConfig` |
1014
1322
  | Structural destruction | `physics/structure` | `StructureGraph`, `StructureNodeSpec`, `StructureEdgeSpec`, `StructureMaterial`, `StructureMaterialTable`, `CollapseEvent`, `DebrisConfig` |
@@ -1024,6 +1332,7 @@ Exact import paths and export names — **do not invent paths**; every row below
1024
1332
  | Session matchmaking | `multiplayer/matchmaking` | `browseSessions`, `findByJoinCode`, `quickMatch`, `matchesFilter`, `normalizeJoinCode`, `generateJoinCode`, `SessionListing`, `MatchFilter` |
1025
1333
  | Auth identity | `multiplayer/identity` | `AuthSession`, `PlayerIdentity`, `sessionPlayer`, `resolveGuestSession` |
1026
1334
  | Text chat | `game/chat`, `multiplayer/chatContract` | `createChat`, `Chat`, `ChatMessage`, `ChatChannelDef`, `whisperChannelId`, `createChatRateLimiter`, `ChatTransport`, `ChatSync`, `createLocalChatTransport` |
1335
+ | Chat filter | `game/chatFilter` | `createChatFilter`, `normalizeChatText`, `ChatFilter`, `ChatFilterConfig`, `ChatFilterResult` — mask/reject blocked words (leet-normalized token match); wire via `ChatDeps.filter` (word lists are game data, the engine ships the mechanism) |
1027
1336
  | Voice seam | `multiplayer/voiceContract` | `VoiceTransport`, `VoiceParticipant`, `VoiceRoute`, `createLocalVoiceTransport`, `createPushToTalk`, `PushToTalkMode` |
1028
1337
  | Race state | `game/race` | `raceTrack`, `RaceTrack`, `createRaceState`, `RaceState`, `RaceEvent`, `RaceWinCondition`, `firstPastPost`, `topK`, `lastStanding`, `everyoneFinishes` |
1029
1338
  | Reveal query | `sensor/revealQuery` | `createRevealQuery`, `RevealQuery`, `RevealQueryOptions`, `RevealHit` |
@@ -1042,6 +1351,8 @@ Exact import paths and export names — **do not invent paths**; every row below
1042
1351
  | Telegraph | `combat/telegraph` | `pointInTelegraph`, `telegraphProgress`, `telegraphFired`, `telegraphTurnProgress`, `telegraphFiredAtTurn`, `telegraphTurnsRemaining`, `TelegraphShape`, `TelegraphConfig` |
1043
1352
  | Dash / dodge | `movement/dash` | `createDashState`, `DashState`, `DashConfig`, `DashBurst`, `iframeActive`, `dashOffset` |
1044
1353
  | Ability kit | `combat/abilityKit` | `createAbilityKit`, `AbilityKit`, `AbilitySlotConfig`, `AbilitySlotSnapshot`, `AbilitySlotState`, `AbilityCastType`, `AbilityCastResult`, `AbilitySlotRetune` |
1354
+ | Resource pool | `combat/resourcePool` | `createResourcePool`, `ResourcePool`, `ResourcePoolConfig` — current/max with per-second regen/decay and spend/gain; `pool.current()` is the ability kit's `resourceAvailable` |
1355
+ | Combo points | `combat/comboPoints` | `createComboPoints`, `ComboPoints`, `ComboPointsConfig` — discrete points accrued on action, expiring after a timeout from the last gain, spent in bulk |
1045
1356
  | Event meter | `stats/eventMeter` | `createEventMeter`, `EventMeter`, `EventMeterConfig`, `EventMeterFeedResult` |
1046
1357
  | Auto-target policy | `scene/autoTarget` | `selectAutoTarget`, `createAutoTargeter`, `AutoTargetPolicy`, `AutoTargeter`, `AutoTargetDeps` |
1047
1358
  | Resistance matrix | `combat/resistance` | `resolveResistance`, `resistanceScale`, `ResistanceMatrix`, `ResistVerdict`, `ResistanceResult` |
@@ -1056,6 +1367,15 @@ Exact import paths and export names — **do not invent paths**; every row below
1056
1367
 
1057
1368
  ## Getting started (new project)
1058
1369
 
1370
+ Fastest path — the `jgengine` CLI scaffolds the entire canonical shape below (harness, skeleton, stub game, verify test, AGENTS.md) as a booting game:
1371
+
1372
+ ```sh
1373
+ npx jgengine create my-game # then: cd my-game && bun dev
1374
+ npx jgengine doctor # later, if the setup drifts (version skew, unstyled HUD, shape strays)
1375
+ ```
1376
+
1377
+ Manual equivalent:
1378
+
1059
1379
  ```sh
1060
1380
  bun add @jgengine/core @jgengine/react @jgengine/shell react react-dom three three-stdlib @react-three/fiber @react-three/drei
1061
1381
  bun add -d @tailwindcss/vite tailwindcss # HUD styling (Vite + Tailwind v4)
@@ -1330,6 +1650,8 @@ Optional render/world fields the shell also reads: `entitySprites` / `entityMode
1330
1650
 
1331
1651
  **Lighting and backdrop.** `PlayableGame.lighting` (`LightingConfig`, `@jgengine/core/game/playableGame`) replaces the shell's hardcoded ambient/directional default when present, regardless of world kind: `ambient?: { color?, intensity? }`, `directional?: { color?, intensity?, position, castShadow? }[]`, `hemisphere?: { skyColor?, groundColor?, intensity? }`. `PlayableGame.backdrop` (`BackdropConfig`) is a generic background/sky/fog for **any** world kind, including a custom `environment` component: `background?: string` (CSS color), `sky?: SkyEnvironmentConfig` (same descriptor `environment()`'s `sky` field takes), `fog?: { color?, near?, far?, density? }` (`density` set switches to exponential `FogExp2` and `near`/`far` are ignored). Both are optional and additive to whatever the world/`environment` renderer already draws.
1332
1652
 
1653
+ **Visibility & streaming (automatic).** Every 3D game gets camera frustum + distance culling for free — the shell's `CullingProvider` reads the live camera each frame, runs the engine `createVisibilitySystem` (`@jgengine/core/visibility/visibilitySystem`) over the scene's entities and placed objects, and toggles `group.visible` so off-screen objects are never submitted to the renderer (never unmounted — gameplay and simulation are untouched). Defaults are conservative (a preload margin larger than the view, hysteresis, `Infinity` default render distance) so existing games only benefit. Tune or opt out via `PlayableGame.visibility` (`VisibilityConfig`, `@jgengine/core/visibility/config`): `enabled: false` disables it; `culling`/`streaming` patch the global `CullingSettings`/`StreamingSettings` (`@jgengine/core/visibility/settings`); `scene` sets scene-wide overrides; `entities`/`objects` override by kind name / catalog id (`alwaysVisible`, `maxRenderDistance`, custom `bounds`, `pinned`, `cullingDisabled`, …). The engine layer also ships `@jgengine/core/visibility/spatialIndex` (the 3D hash the culler queries instead of scanning every object), `@jgengine/core/visibility/assetStreaming` (dedup/budget/grace-period asset loading), and `@jgengine/core/visibility/simulationCulling` (opt-in, off by default — throttles low-priority off-screen updates, never protected entities). Full reference: `packages/core/src/visibility/README.md`.
1654
+
1333
1655
  **Player movement tuning** — the `movement` field (`PlayerMovementConfig`) tunes the shell's local-player walk controller beyond `physics.gravity`/`jumpVelocity`: `mode` (`"free"` camera-relative default, `"axis"` locks travel to one world `axis`, `"grid"` snaps each committed step to `cellSize` centers), `collideObjects` (collide against placed scene objects as unit-box AABBs even without `collision.voxel`), and `beforeCommit(frame)` — an escape hatch called each frame with `{ entityId, current, next, dt }` that can return a replacement `[x, y, z]` to constrain or redirect the step (rails, bounds, custom collision) before it commits and before `onTick` runs.
1334
1656
 
1335
1657
  The runner boots `createGameContext({ definition, content, player: { userId, isNew } })`, calls `loop.onInit(ctx)` then `loop.onNewPlayer(ctx)`, and drives `loop.onTick(ctx, dt)` per frame. **Convention: `onNewPlayer` spawns the player entity with `id === ctx.player.userId`** — bounded stats, targeting, and kill attribution key off that.
@@ -1417,384 +1739,611 @@ ctx.time advance, now, calendar, snapshot; pause, play, toggle,
1417
1739
  ctx.subscribe / ctx.version change signal — UI layers bind via useSyncExternalStore
1418
1740
  ```
1419
1741
 
1420
- `content.itemById(id)` supplies `{ use?, weapon?, trade? }`; `content.entityById(id)` supplies `{ stats?, receive?, onDeath?, movement?, role? }`; `content.objectById(id)` supplies `GameContextObjectEntry` `{ proximityPrompt?, breakable?, slotInventory? }`. Build all three from your catalogs in `content.ts`. A placed object resolves its catalog entry via `ctx.scene.object.catalog(instanceId)`; `ctx.scene.object.at(x, y, z, tolerance?)` finds placed objects near a point (grid interaction, click resolution beyond the pointer service). `ctx.scene.entity.update(id, patch)` writes a shallow patch onto a spawned entity's mutable fields (e.g. `movement.walkSpeed`) without a full respawn — `scene/movementSpeed`'s `applyStatDrivenSpeed(deps, id, { baseSpeed, multiplierStat?, flatBonusStat? })` is the catalog-driven helper that recomputes and writes `movement.walkSpeed` from a stat-modifier pair each time a buff changes.
1742
+ `content.itemById(id)` supplies `{ use?, weapon?, trade? }`; `content.entityById(id)` supplies `{ stats?, receive?, onDeath?, movement?, role? }`; `content.objectById(id)` supplies `GameContextObjectEntry` `{ proximityPrompt?, breakable?, slotInventory? }`. Build all three from your catalogs in `content.ts`. A placed object resolves its catalog entry via `ctx.scene.object.catalog(instanceId)`; `ctx.scene.object.at(x, y, z, tolerance?)` finds placed objects near a point (grid interaction, click resolution beyond the pointer service). `ctx.scene.entity.update(id, patch)` writes a shallow patch onto a spawned entity's mutable fields (e.g. `movement.walkSpeed`) without a full respawn — `scene/movementSpeed`'s `applyStatDrivenSpeed(deps, id, { baseSpeed, multiplierStat?, flatBonusStat? })` is the catalog-driven helper that recomputes and writes `movement.walkSpeed` from a stat-modifier pair each time a buff changes.
1743
+
1744
+ ### Two tiers: `ctx` runtime vs pure factories
1745
+
1746
+ The `ctx` surface above is the **stateful runtime** — it's what game code uses. Every subsystem it wires is *also* exported as a **pure factory** that `createGameContext` composes internally: `createTradeSystem`, `createDeathSystem`, `createEffectSystem`, `createProjectileSystem`, `createSpatialApi`, `createEntityStatsApi`, `createEntityStore`, `createObjectStore`, `createStats`, `createLoadouts`, `createLootRegistry`, `createQuestJournal`, `createSocial`, `createSlots`, `createInteriors` (plus stateless helpers beside each — `canAffordCosts`/`resolveBuy` in `game/trade`, `getStatValue`/`applyPoolDelta` in `scene/entityStats`, and so on). **Build a game through `ctx`, not these** — reach for the factories only for unit tests of pure game math, headless servers, or a custom runtime. Import the domain deep path (`@jgengine/core/combat/death`, `@jgengine/core/game/trade`, `@jgengine/core/stats/statModifiers`, …) and read the `.d.ts`; each is a real export in the published package.
1747
+
1748
+ `createSpatialApi`'s optional `grid: { cellSize }` opts `inRadius`/`queryArc` into a lazily-built x/z broadphase index over `candidates()` instead of a linear scan — worth it once candidate counts run into the hundreds+. The index is reused across calls until `invalidate()` is called, so call it after any position change (move, spawn, despawn); a candidate outside the index at query time still resolves exactly (never silently skipped), only a *moved* one can be missed until invalidated.
1749
+
1750
+ ## `loop` — lifecycle
1751
+
1752
+ ```ts
1753
+ export function onInit(ctx: GameContext) {
1754
+ ctx.item.use.register(itemUseHandlers);
1755
+ ctx.player.loadout.register(loadouts);
1756
+ for (const table of lootTables) ctx.game.loot.register(table);
1757
+ ctx.game.quest.register(quests);
1758
+ ctx.game.quest.bind("entity.died");
1759
+ ctx.game.feed.bind("entity.died");
1760
+ ctx.game.events.on("entity.died", (evt) => onEntityDied(ctx, evt));
1761
+ setupWorld(ctx);
1762
+ }
1763
+
1764
+ export function onNewPlayer(ctx: GameContext) {
1765
+ ctx.scene.entity.spawn("player_default", { id: ctx.player.userId, position: spawnPoint });
1766
+ if (ctx.player.isNew) ctx.player.applyLoadout(ctx.player.userId, "starterKit");
1767
+ }
1768
+
1769
+ export function onTick(ctx: GameContext, dt: number) {
1770
+ // AI, regen, respawn timers — dt is GAME time (see ctx.time). Never death detection (see entity.died)
1771
+ }
1772
+ ```
1773
+
1774
+ `onInit` runs once per boot; register everything there. Loot tables register through `ctx.game.loot.register` — `lootTable()` is a pure validating factory, there is no global side-effect registry.
1775
+
1776
+ ## `ctx.time` — the simulation clock
1777
+
1778
+ `onTick`'s `dt` is **game time, not real time**: the shell scales each frame's real delta by `definition.time.scale` (real→game seconds at 1×) and the live speed multiplier, so writing decay/regen/AI as `rate * dt` makes it obey pause and fast-forward for free — never read wall-clock in a tick. Configure via `defineGame({ time: { scale?, speeds?, dayLength?, start?, startPaused?, daysPerYear?, seasons? } })` (all optional; default is real-time 1:1 with speeds `[1,2,3,4]`, 365-day years).
1779
+
1780
+ - **Continuous** work scales through `dt`. **Scheduled** work uses game-time timers: `ctx.time.after(sec, cb)`, `ctx.time.every(sec, cb)`, `ctx.time.at(gameSec, cb)` — measured in game-seconds, so 4× fires them 4× sooner and pause freezes them. Each returns a cancel handle.
1781
+ - **Controls** (drive from a HUD or a command): `pause()`, `play()`, `toggle()`, `setSpeed(mult)` (0 pauses), `cycleSpeed()`. Read state with `ctx.time.snapshot()` / `ctx.time.calendar()` (`{ day, hour, minute, second, dayFraction, year, dayOfYear, yearFraction, season? }`), or in React with `useGameClock()` → snapshot + `controls`. Speeding to 4× or pausing affects **everything** on the tick — no per-system wiring.
1782
+ - **Calendar year/season** rides the same day counter, no separate clock: `year`/`dayOfYear` fall out of `day` divided by `TimeConfig.daysPerYear` (default 365), `yearFraction` is progress through the current year (0..1). Setting `TimeConfig.seasons: string[]` (e.g. `["spring","summer","fall","winter"]`) slices the year into equal named segments and populates `calendar().season`; omit `seasons` and the field is absent — a living-world sim names its own season boundaries this way instead of a hand-rolled `dayOfYear % ...` module.
1783
+
1784
+ ### Beat clock — BPM signal + input quantization
1785
+
1786
+ `@jgengine/core/time/beatClock` is a separate, purpose-built signal from `simClock` — a BPM tick generator for rhythm games (Hi-Fi Rush–style quantized combat), not a day/pause clock. `createBeatClock({ bpm, beatsPerBar? }, onBeat?)` returns a `BeatClock`: call `advance(gameDt)` from `onTick` with the same **game-time** `dt` (never wall-clock) — it fires `onBeat(beatIndex)` once per newly crossed integer beat and returns a `BeatSnapshot` (`beat`, `beatIndex`, `bar`, `beatInBar`, `phase`). `createBeatInputBuffer<T>(beatDurationSec)` is the auto-correct input buffer: `buffer(action, nowSec)` quantizes an off-beat press to fire on the next beat tick (or immediately if pressed exactly on one); `advance(nowSec)` drains and returns every action whose beat has arrived. `nextBeatTime(nowSec, beatDurationSec)` is the underlying pure quantization function. Feed a music track's actual BPM in; the buffer is what makes an early/late input still land on-beat.
1787
+
1788
+ ## Content catalogs
1789
+ ## `ctx.game.store` — reactive game state
1790
+
1791
+ ```ts
1792
+ ctx.game.store.set("health", 100) // any key, any value type
1793
+ ctx.game.store.get("health") // T | undefined
1794
+ ctx.game.store.has("health")
1795
+ ctx.game.store.delete("health")
1796
+ ctx.game.store.subscribe(listener) // change-signal fires on set/delete
1797
+ ctx.game.store.mapSnapshot() / arraySnapshot()
1798
+ ```
1799
+
1800
+ A reactive per-game keyed store (`ObservableKeyedStore<unknown>`) attached to `GameContext` — reach for it instead of a module-level singleton store for ad-hoc reactive game state (turn trackers, deck UIs, anything that doesn't already have a `ctx` surface). `set`/`delete` bump `ctx.version()` and notify `ctx.subscribe` listeners; `get`/`has` are plain reads. Unlike a per-slot handle, there is no `define`/seed step — a key simply doesn't exist until the first `set`.
1801
+
1802
+ ## `ctx.game.cards` / `ctx.game.turn` — lazily-created piles and turn loops
1803
+
1804
+ `ctx.game.cards.pile(id, config?)` and `ctx.game.turn.loop(id, config?)` lazily create (config required on first call) or return the existing notify-wrapped `CardPile`/`TurnLoop` for `id` — call with just the id after the first `onInit` seed to fetch the same instance; every mutating method is wrapped so it bumps `ctx.version()`/notifies `ctx.subscribe` automatically, same as every other `ctx` surface. This replaces manually constructing `createCardPile`/`createTurnLoop` and wiring notification yourself.
1805
+
1806
+ ## Movement, pose, input
1807
+ ## External data — `data/dataSource` and the dev proxy
1808
+
1809
+ Renderer-free async-state primitives (`@jgengine/core/data`) for a game that reads a live external source (a leaderboard API, a session browser, remote config) — distinct from `ctx.game.store`/multiplayer, which are for the game's own authoritative state.
1810
+
1811
+ - **`createDataSource(load, options?)`** (`data/dataSource`) → `DataSource<T>` wraps one `load(signal)` async call as `{ status: "idle"|"loading"|"ready"|"error", data, error }`. `getState()` reads the current snapshot, `subscribe(listener)` fires on every change, `refresh({ force? })` re-runs `load` (de-duplicates a call already in flight unless `force`; aborts the prior call first when forced), `startPolling(intervalMs?)`/`stopPolling()` run `refresh` on an interval (`intervalMs` falls back to the one passed at construction; throws if neither is given), `dispose()` tears down polling and in-flight requests for good. Pass `options.clock` (`{ setInterval, clearInterval }`) to swap the timer source in tests.
1812
+ - **`fetchJson<T>(url, options?)`** (`data/fetchJson`) — `fetch` + JSON-parse in one call; throws `HttpStatusError` (`status`, `statusText`, `url`) on a non-OK response and `JsonParseError` (`url`, `cause`) on unparsable JSON, so a `DataSource`'s `error` is always one of these two typed shapes, never a bare `Error`. `options.fetchImpl` swaps the fetch implementation for tests/SSR.
1813
+ - **`createJsonDataSource<T>(url, options?)`** (`data/jsonDataSource`) — sugar combining the two above: a `DataSource<T>` whose `load` calls `fetchJson(url, options)`.
1814
+ - **Dev proxy (`data/devProxy`)** — same-origin routing for external APIs during `bun dev` so browser CORS never blocks a game's `fetchJson` call against a third-party host. `parseDevProxyTable(raw)` parses a `VITE_JGENGINE_DEV_PROXY` env value (a JSON object of `{ routeName: "https://api.example.com" }`) into a `DevProxyTable`; `proxiedUrl(target, { dev?, table?, prefix? })` rewrites a `target` URL whose prefix matches a table entry into `/proxy/<routeName>/<rest>` (default prefix `/proxy`) when `dev` is true (defaults to `import.meta.env.DEV`) — else returns `target` unchanged, so the same call hits the real host in production. `apps/dev`'s `vite.config.ts` reads the same env var and wires a matching Vite server `proxy` entry per route (`changeOrigin: true`, strips the `/proxy/<routeName>` prefix) — set `VITE_JGENGINE_DEV_PROXY` once and both sides (the URL rewrite and the actual proxy route) agree.
1815
+
1816
+ ## Multiplayer and the backend seam
1817
+ ## Genre cheat sheet
1818
+
1819
+ - **Voxel/crafting**: objects for blocks/machines, `voxel()`, `object.break`/`object.placeFromInventory`.
1820
+ - **Tycoon/lab**: objects + `slotInventory`, `plots()`, configure via prompt → command.
1821
+ - **Shooter**: `fireProjectile`/`settleProjectile`; grenades settle → `effect({ at, radius })`; `movement.poses`/`aim` + zoom modifier; `servers({ … })` + game-owned `server.mode`; loadout classes from commands.
1822
+ - **MMO/RPG**: bounded stats + `leveling()` over a game XP curve; `tabTarget` → `cycleTarget`; handlers read `getTarget`; quests bound to `entity.died`/`inventory.added`; social party + `partyShare`; `server: "persistent"`.
1823
+ - **All combat games**: react to `entity.died` (feed/leaderboard/score) — never poll HP.
1824
+
1825
+ ## Anti-patterns
1826
+
1827
+ | Wrong | Right |
1828
+ |-------|-------|
1829
+ | Player tuning in `defineGame` | Entity catalog `movement` + stats |
1830
+ | `behaviors: […]` on place/spawn | Catalog entry |
1831
+ | Engine `weapon.fire` / `consumable.use` / `combat.*` | `item.use` + catalog `use` → game handler |
1832
+ | `ItemUseInput.to` for targets | `getTarget(from)` in handlers |
1833
+ | `effect({ to })` for gunshots | `fireProjectile` + `settleProjectile` |
1834
+ | Polling HP in `onTick` for kills | `entity.died` event |
1835
+ | `combat.lootTable` / `loot.enemy` | `onDeath` on the entity that died |
1836
+ | Hand-rolled `Math.random()` loot in commands | `lootTable()` + `ctx.game.loot.roll` |
1837
+ | Hand-rolled `xpForLevel`/`levelFromXp` | `game/progression` `curve()` + `leveling()` |
1838
+ | Hardcoded shop arrays | `item.trade.shops` + `tradableAt` |
1839
+ | Kit seeding via scattered `put`/`grant` | `applyLoadout` |
1840
+ | Per-user quest state hand-rolled | `game.quest.register` + binds |
1841
+ | `useKillFeed` / per-domain feed hooks | `useFeed({ action })` |
1842
+ | Raw keys in game logic | `defineGame` input actions |
1843
+ | Positioning inside `ui/components/` or on primitives (`CurrencyPill className="absolute …"`) | Screen wrappers in `GameUI.tsx` only |
1844
+ | Game UI classes without `@source` in host CSS | `@source` entries for your game dirs + `node_modules/@jgengine/{shell,react}` |
1845
+ | One file per catalog entry / per brand | Dense `<domain>/catalog.ts` |
1846
+ | Convex mutations called from game code | `commands.run` through the `GameBackend` transport |
1847
+ | Half a system: quest without tracker, cooldown without sweep, keybind never shown, stub "coming soon" modal | Finish the system end to end — or cut it whole (see `jgengine`) |
1848
+ | Game-side workaround for a missing engine primitive | File the gap at github.com/Noisemaker111/jgengine/issues (or PR the primitive) and cut or scope the dependent system honestly |
1849
+ | Game nouns in this skill | Engine primitives + placeholder ids only |
1850
+
1851
+ ## New-game definition of done
1852
+
1853
+ This is a gate, not a suggestion — every box, in one pass (workflow: **`jgengine`** skill). "Compiles and the hooks are wired" is not done; a declared system with no UI, no feedback, or no way to exercise it is not done — finish the system or cut it whole.
1854
+
1855
+ - [ ] `game.config.ts` (`defineGame` from `@jgengine/shell/defineGame`) + `index.tsx` (barrel) + `main.tsx` (standalone host) + `loop.ts` + `game/content.ts`
1856
+ - [ ] Catalogs: `game/entities/<role>/catalog.ts`, `game/items/<domain>/catalog.ts`, `game/objects/catalog.ts`; loot tables beside their domain
1857
+ - [ ] Entity `stats` + `receive` orders aligned on the same stat ids; `role` set (drives targeting + camera)
1858
+ - [ ] `game/items/use-handlers.ts` registered in `onInit`; handlers read `getTarget`/`aim`, never a target input
1859
+ - [ ] `game/loadouts.ts` + `applyLoadout` in `onNewPlayer` (gated on `isNew`)
1860
+ - [ ] `game/quests/catalog.ts` + binds; if using xp/level, a game-owned curve fed to `game/progression` (`curve`/`leveling`) — **with their HUD/tracker, or cut**
1861
+ - [ ] `onInit`: register handlers/loadouts/loot/quests, event listeners, feed binds, leaderboard tracks; `setupWorld`
1862
+ - [ ] Player spawns with `id === ctx.player.userId`
1863
+ - [ ] `game/ui/GameUI.tsx` owns layout; components use `@jgengine/react` hooks
1864
+ - [ ] UI passes the **quality bar** above (contrast, scale, framing, genre fit) — not just hook wiring
1865
+ - [ ] Camera tuned via `camera` in `defineGame({...})` — defaults untouched means the feel was never checked
1866
+ - [ ] For an `environment()` world: a `<game>.world.test.ts` asserts `summarizeEnvironment(world)` (`@jgengine/core/world/environmentSummary`) is non-empty with the expected counts — the browserless scene-correctness gate
1867
+ - [ ] HUD screenshotted over a staged `GameUiPreview` scenario and **judged by looking at the image** against the UI quality bar in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md) — the final human glance, not the verification loop
1868
+ - [ ] Co-located bun tests for pure game math (curves, cooldowns, spawn logic)
1869
+ - [ ] Multiplayer via adapter config only; no direct backend calls
1870
+
1871
+ ## Quick reference
1872
+
1873
+ ```
1874
+ defineGame (shell) engine fields (assets, world, physics, inventories, input, server, save, time, feed, multiplayer)
1875
+ + presentation fields (content, loop, GameUI, camera, environment, shadows, movement, devtools, …) in one call — smart defaults fill the rest
1876
+ defineGame (core) the underlying engine-only primitive: assets, world, physics, inventories, input, server, save, time, feed, multiplayer, loop
1877
+ PlayableGame { game, content, loop, GameUI, camera, … } — the runner contract `defineGame` (shell) returns
1878
+ GameContext ctx.scene / ctx.game / ctx.player / ctx.item / ctx.camera / ctx.input + subscribe/version
1879
+ scene.object place, remove, move, rotate, at, setVisual (per-instance ObjectVisual: scale/color/opacity override)
1880
+ scene.entity spawn (anchor/offset), despawn, setPose, update; stats; targeting; effects;
1881
+
1882
+ # jgengine-ui
1883
+
1884
+ Use this skill for the **visual and interaction design of the game interface**: title screens, HUDs, menus, prompts, maps, inventories, dialogue, touch controls, transitions, pause/results states, accessibility, and screenshot critique.
1885
+
1886
+ Do not use this skill as a React, routing, state-management, or hooks reference. The main `jgengine` skill owns routing and points to the engine APIs. When implementation needs `@jgengine/react` hooks or shell APIs, follow the links in the main skill and the compact API appendix in [reference.md](reference.md); keep this skill focused on what the player sees and feels.
1887
+
1888
+ ## Required outcome
1889
+
1890
+ A JGengine game must read as a self-contained game, not a responsive website with a canvas inside it.
1891
+
1892
+ Before shipping UI:
1893
+
1894
+ 1. Give the game a concise UI art direction.
1895
+ 2. Compose explicit desktop/mobile game layouts instead of document flow.
1896
+ 3. Keep persistent HUD information sparse and hierarchical.
1897
+ 4. Adapt touch controls to the genre and reserve their screen zones.
1898
+ 5. Implement authored focus, pressed, selected, disabled, success, failure, and warning states.
1899
+ 6. Add purposeful motion and feedback.
1900
+ 7. Capture screenshots and revise what actually renders.
1901
+
1902
+ ## Ownership boundary
1903
+
1904
+ The main `jgengine` skill owns intake, engine architecture, API routing, hooks, commands, state, and verification routing. This skill owns presentation quality.
1905
+
1906
+ Read [reference.md](reference.md) when building or reviewing a game interface. It contains the implementation quality bar, layout rules, art-direction template, touch-control requirements, acceptance criteria, and the compact existing React API surface.
1907
+
1908
+ ## Non-negotiable defaults
1909
+
1910
+ - Active play owns the viewport; no marketing header, page title bar, document scrolling, or website container.
1911
+ - Screen placement belongs in the game's `ui/GameUI.tsx` composition layer.
1912
+ - Persistent gameplay information is frameless unless a physical/diegetic frame is part of the game's art direction.
1913
+ - Instructions are contextual and temporary, not permanent keyboard grids.
1914
+ - Mobile controls share input mechanics but not one universal visual skin.
1915
+ - Themes change geometry, composition, typography roles, icons, motion, materials, sound, and density—not only colors.
1916
+ - Ordinary rounded cards, pill buttons, generic dark modals, and dashboard grids are fallback failures, not defaults.
1917
+
1918
+ ## Preview states ship with the UI
1919
+
1920
+ Every game ships `src/preview.tsx`: a static default frame (default export, used by the website card) plus a `states` named export (`GamePreviewStates` from `@jgengine/react/preview`) keying named UI states — `stage_1`, `game_over`, `boss_intro` — to components. Build state entries from the game's **real UI components** with fixture snapshots (canned props/state), not redrawn lookalikes; that turns every key into a capturable render test. Capture any state instantly with `bun run shoot <game> --preview <stateKey>` — no sim, no three.js, no hang risk — and use it as the screenshot-critique loop for HUD/menu/overlay work before any full-shell `--mode ui`/`play` glance.
1921
+
1922
+ ## Rejection test
1923
+
1924
+ Reject and revise the UI when it could be mistaken for a SaaS dashboard, landing page, admin panel, documentation page, or generic emulator overlay.
1925
+
1926
+ # JGengine UI — game presentation reference
1927
+
1928
+ This reference defines the required visual and interaction quality for JGengine games. The main `jgengine` skill owns engine architecture, hooks, input commands, and routing. This document owns what the interface looks like, how it is composed, how it responds, and how it is verified.
1929
+
1930
+ ## The rule
1931
+
1932
+ A game must visually own its viewport. It must not resemble a dashboard, landing page, documentation page, or ordinary responsive web app.
1933
+
1934
+ HTML and React are valid implementation tools. Website visual grammar is not the default.
1935
+
1936
+ ## 1. Start with a concise UI art direction
1937
+
1938
+ Before implementing screens, write this short block:
1939
+
1940
+ ```md
1941
+ UI ART DIRECTION
1942
+
1943
+ Player fantasy:
1944
+ Emotional tone:
1945
+ Shape language:
1946
+ Material language:
1947
+ Typography roles: display / body / numerical / labels
1948
+ Motion language:
1949
+ Icon language:
1950
+ Sound language:
1951
+ Information hierarchy:
1952
+ Forbidden patterns:
1953
+ ```
1954
+
1955
+ Keep it practical. It should directly influence layout, silhouettes, controls, timing, and materials.
1956
+
1957
+ Example forbidden patterns:
1958
+
1959
+ - generic rounded dashboard cards
1960
+ - pill buttons
1961
+ - long centered paragraphs during play
1962
+ - ordinary two-column form layouts
1963
+ - persistent keyboard-instruction grids
1964
+ - multiple equally weighted bordered panels
1965
+ - generic translucent mobile circles
1966
+ - large website-style modals
1967
+ - document-flow wrapping used as HUD layout
1968
+
1969
+ A theme is not complete when only colors and fonts change. It must also affect composition, geometry, spacing rhythm, borders, icons, animation, sound, information density, terminology, button construction, and touch controls.
1970
+
1971
+ ## 2. Screen inventory and hierarchy
1972
+
1973
+ Identify the screens the game actually needs:
1421
1974
 
1422
- ### Two tiers: `ctx` runtime vs pure factories
1975
+ - boot/loading
1976
+ - title or attract screen
1977
+ - mode selection
1978
+ - onboarding/tutorial
1979
+ - gameplay HUD
1980
+ - pause
1981
+ - settings
1982
+ - map/inventory/dialogue where relevant
1983
+ - victory/results
1984
+ - failure/retry
1423
1985
 
1424
- The `ctx` surface above is the **stateful runtime** — it's what game code uses. Every subsystem it wires is *also* exported as a **pure factory** that `createGameContext` composes internally: `createTradeSystem`, `createDeathSystem`, `createEffectSystem`, `createProjectileSystem`, `createSpatialApi`, `createEntityStatsApi`, `createEntityStore`, `createObjectStore`, `createStats`, `createLoadouts`, `createLootRegistry`, `createQuestJournal`, `createSocial`, `createSlots`, `createInteriors` (plus stateless helpers beside each — `canAffordCosts`/`resolveBuy` in `game/trade`, `getStatValue`/`applyPoolDelta` in `scene/entityStats`, and so on). **Build a game through `ctx`, not these** — reach for the factories only for unit tests of pure game math, headless servers, or a custom runtime. Import the domain deep path (`@jgengine/core/combat/death`, `@jgengine/core/game/trade`, `@jgengine/core/stats/statModifiers`, …) and read the `.d.ts`; each is a real export in the published package.
1986
+ For each screen, define:
1425
1987
 
1426
- `createSpatialApi`'s optional `grid: { cellSize }` opts `inRadius`/`queryArc` into a lazily-built x/z broadphase index over `candidates()` instead of a linear scan — worth it once candidate counts run into the hundreds+. The index is reused across calls until `invalidate()` is called, so call it after any position change (move, spawn, despawn); a candidate outside the index at query time still resolves exactly (never silently skipped), only a *moved* one can be missed until invalidated.
1988
+ - the player’s primary question
1989
+ - the primary action
1990
+ - the most important information
1991
+ - what can be hidden
1992
+ - what belongs in-world instead of in the HUD
1427
1993
 
1428
- ## `loop` — lifecycle
1994
+ ### HUD tiers
1429
1995
 
1430
- ```ts
1431
- export function onInit(ctx: GameContext) {
1432
- ctx.item.use.register(itemUseHandlers);
1433
- ctx.player.loadout.register(loadouts);
1434
- for (const table of lootTables) ctx.game.loot.register(table);
1435
- ctx.game.quest.register(quests);
1436
- ctx.game.quest.bind("entity.died");
1437
- ctx.game.feed.bind("entity.died");
1438
- ctx.game.events.on("entity.died", (evt) => onEntityDied(ctx, evt));
1439
- setupWorld(ctx);
1440
- }
1996
+ **Tier 1 — immediate action and survival**
1997
+ Health, timer, current target, ammo, danger, capture state.
1441
1998
 
1442
- export function onNewPlayer(ctx: GameContext) {
1443
- ctx.scene.entity.spawn("player_default", { id: ctx.player.userId, position: spawnPoint });
1444
- if (ctx.player.isNew) ctx.player.applyLoadout(ctx.player.userId, "starterKit");
1445
- }
1999
+ **Tier 2 short-term decisions**
2000
+ Objective progress, route progress, cooldowns, combo, pursuit distance.
1446
2001
 
1447
- export function onTick(ctx: GameContext, dt: number) {
1448
- // AI, regen, respawn timers dt is GAME time (see ctx.time). Never death detection (see entity.died)
1449
- }
1450
- ```
2002
+ **Tier 3 reference information**
2003
+ Full map, inventory, controls, schedule, mission details, lore.
1451
2004
 
1452
- `onInit` runs once per boot; register everything there. Loot tables register through `ctx.game.loot.register` `lootTable()` is a pure validating factory, there is no global side-effect registry.
2005
+ Tier 1 is immediately readable. Tier 2 is quieter. Tier 3 is usually hidden until requested.
1453
2006
 
1454
- ## `ctx.time` the simulation clock
2007
+ Do not style every datum as an equally important bordered box.
1455
2008
 
1456
- `onTick`'s `dt` is **game time, not real time**: the shell scales each frame's real delta by `definition.time.scale` (real→game seconds at 1×) and the live speed multiplier, so writing decay/regen/AI as `rate * dt` makes it obey pause and fast-forward for free — never read wall-clock in a tick. Configure via `defineGame({ time: { scale?, speeds?, dayLength?, start?, startPaused?, daysPerYear?, seasons? } })` (all optional; default is real-time 1:1 with speeds `[1,2,3,4]`, 365-day years).
2009
+ ## 3. Full-viewport game composition
1457
2010
 
1458
- - **Continuous** work scales through `dt`. **Scheduled** work uses game-time timers: `ctx.time.after(sec, cb)`, `ctx.time.every(sec, cb)`, `ctx.time.at(gameSec, cb)` — measured in game-seconds, so 4× fires them 4× sooner and pause freezes them. Each returns a cancel handle.
1459
- - **Controls** (drive from a HUD or a command): `pause()`, `play()`, `toggle()`, `setSpeed(mult)` (0 pauses), `cycleSpeed()`. Read state with `ctx.time.snapshot()` / `ctx.time.calendar()` (`{ day, hour, minute, second, dayFraction, year, dayOfYear, yearFraction, season? }`), or in React with `useGameClock()` → snapshot + `controls`. Speeding to 4× or pausing affects **everything** on the tick — no per-system wiring.
1460
- - **Calendar year/season** rides the same day counter, no separate clock: `year`/`dayOfYear` fall out of `day` divided by `TimeConfig.daysPerYear` (default 365), `yearFraction` is progress through the current year (0..1). Setting `TimeConfig.seasons: string[]` (e.g. `["spring","summer","fall","winter"]`) slices the year into equal named segments and populates `calendar().season`; omit `seasons` and the field is absent — a living-world sim names its own season boundaries this way instead of a hand-rolled `dayOfYear % ...` module.
2011
+ The active game should behave like an application mode:
1461
2012
 
1462
- ### Beat clock BPM signal + input quantization
2013
+ - own the full viewport
2014
+ - avoid document scrolling
2015
+ - avoid site navigation and marketing chrome during play
2016
+ - respect safe-area insets
2017
+ - keep exit/settings/fullscreen controls minimal
2018
+ - separate world, HUD, controls, screens, and system overlays
1463
2019
 
1464
- `@jgengine/core/time/beatClock` is a separate, purpose-built signal from `simClock` — a BPM tick generator for rhythm games (Hi-Fi Rush–style quantized combat), not a day/pause clock. `createBeatClock({ bpm, beatsPerBar? }, onBeat?)` returns a `BeatClock`: call `advance(gameDt)` from `onTick` with the same **game-time** `dt` (never wall-clock) — it fires `onBeat(beatIndex)` once per newly crossed integer beat and returns a `BeatSnapshot` (`beat`, `beatIndex`, `bar`, `beatInBar`, `phase`). `createBeatInputBuffer<T>(beatDurationSec)` is the auto-correct input buffer: `buffer(action, nowSec)` quantizes an off-beat press to fire on the next beat tick (or immediately if pressed exactly on one); `advance(nowSec)` drains and returns every action whose beat has arrived. `nextBeatTime(nowSec, beatDurationSec)` is the underlying pure quantization function. Feed a music track's actual BPM in; the buffer is what makes an early/late input still land on-beat.
2020
+ Recommended layer contract:
1465
2021
 
1466
- ## Content catalogs
2022
+ ```tsx
2023
+ <GamePlayer>
2024
+ <WorldLayer />
2025
+ <HudLayer />
2026
+ <ControlLayer />
2027
+ <ScreenLayer />
2028
+ <SystemLayer />
2029
+ </GamePlayer>
2030
+ ```
1467
2031
 
1468
- ### Object catalog fields
2032
+ - `WorldLayer`: game renderer
2033
+ - `HudLayer`: non-blocking gameplay information
2034
+ - `ControlLayer`: touch/input surfaces
2035
+ - `ScreenLayer`: title, pause, settings, tutorial, victory, failure, transitions
2036
+ - `SystemLayer`: exit, fullscreen, engine settings, devtools
1469
2037
 
1470
- | Field | Purpose |
1471
- |-------|---------|
1472
- | `id`, `model` | Canonical id, asset key |
1473
- | `footprint` | `{ w, h, d }` placement bounds |
1474
- | `snap` | `"grid"` \| `"free"` \| `"wall"` |
1475
- | `solid` | Blocks movement |
1476
- | `breakable` | `false` or `{ baseBreakTime, harvest, drops, dropsWhenUnmet }` |
1477
- | `proximityPrompt` | Float UI + optional command invoke |
1478
- | `slotInventory` | Attached container `{ slots, accepts }` created at place time (`object:<instanceId>`) |
2038
+ Do not place unrelated interface pieces into one ordinary DOM flow.
1479
2039
 
1480
- Break resolution: `duration = baseBreakTime / (tool?.breakSpeed ?? 1)`; drops per `when` (`always` / `harvestMet` / `silkTouch` / `playerKill`); then `inventory.put` + `object.remove`.
2040
+ ## 4. Explicit game layout modes
1481
2041
 
1482
- ### Item catalog fields
2042
+ Do not rely on generic responsive wrapping. Compose explicit modes such as:
1483
2043
 
1484
- | Field | Purpose |
1485
- |-------|---------|
1486
- | `id`, `kind`, `stack`, `model` | Basics; `stack` feeds `itemTraits.stackLimit` |
1487
- | `use` | Game handler name dispatched by `item.use` (`"fireGun"`, `"castBolt"`, `"drinkPotion"`) |
1488
- | `weapon` | Stats the handler reads via `item.weapon.getStat` — `damage`, `heal`, `reach`, `manaCost`, `projectile.{mass,gravity,fuseTime,settleOn}`, `explosion.{radius}` … |
1489
- | `trade` | `{ buy?: {coins: 80}, sell?, shops?: ["shop_town"] }` |
1490
- | `requires` | Unlock ids gating purchase/use |
1491
- | `placesObject` | Object id placed from hotbar |
1492
- | `rarity`, `baseType` | Read by the `worldItem` rarity render binding + loot filter when this item drops to the ground (#32/#33); `baseType` defaults to the item id when absent |
2044
+ - desktop-wide
2045
+ - desktop-compact
2046
+ - mobile-landscape
2047
+ - mobile-portrait
1493
2048
 
1494
- ### Entity catalog fields
2049
+ A mobile layout is not a shrunken desktop HUD.
1495
2050
 
1496
- | Field | Purpose |
1497
- |-------|---------|
1498
- | `movement` | `walkSpeed` (reaches spawn automatically), `poses?: ["standing","crouch","prone","running"]`, `aim?: ["hip","ads"]`, `frozen?: boolean` — a scene-instance-level movement lock (cutscenes, stuns, mount transitions); `movedWhileFrozen(entity)` (`scene/entityStore`) flags an entity whose velocity moved anyway despite `frozen: true`, catching a system that bypassed the lock |
1499
- | `role` | `CatalogEntityRole` = `"player"` \| `"enemy"` \| `"hostile"` \| `"npc"` \| `"vehicle"` — catalog hostility class for targeting (`"enemy"`/`"hostile"` classify hostile in `cycleTarget`). Distinct from the scene *instance* `EntityRole` (`"player"` \| `"npc"` \| `"prop"`, in `scene/entityStore`) which drives input/camera binding — **possession** (`ctx.player.possession`) flips this instance role between `"player"`/`"npc"` on every control swap, so exactly one owned entity is ever the input/camera target |
1500
- | `stats` | Stat declarations — bounded values: `{ health: { max: 120, min: 0 }, level: { max: 60, min: 1, current: 1 }, … }` — `current` optional, defaults to `max` |
1501
- | `receive` | Per-effect absorption: `{ damage: { order: ["shield","health"], modifiers? }, heal: { order: ["health"] } }` — keyed by **game-defined effect ids**; presence = can receive |
1502
- | `onDeath` | `{ drops: "table_id" }` or reason-aware `{ drops: [{ table, when: { reason: "player_kill" } }], command?: { name, when? } }` |
1503
- | `wander`, `talkable` | AI descriptor; dialogue id sugar for a talk prompt |
2051
+ On mobile:
1504
2052
 
1505
- ### Dialogue catalog
2053
+ - reserve thumb-control zones
2054
+ - keep critical HUD out of those zones
2055
+ - hide keyboard legends
2056
+ - reduce persistent information
2057
+ - move Tier 3 information behind contextual panels
2058
+ - respect browser and device safe areas
2059
+ - support portrait only when intentionally designed
2060
+ - otherwise show a polished rotate-device state
1506
2061
 
1507
- `entities/npcs/dialogues.ts` `{ id, lines: [{ speaker, text } | { choices: [{ label, invoke: { command, args } | null }] }] }`. Choices invoke `quest.accept`, `trade.open`, etc. Types ship from `@jgengine/react/components` (`DialogueDef`, `DialogueChoice`, `DialogueLine`) so a game imports them rather than redeclaring — the `DialogueBox` component renders the same shape it types.
2062
+ All viewport anchoring should live in the game’s top-level UI composition file. Child components own their internal layout, not their screen position.
1508
2063
 
1509
- A choice may gate its branch behind a roll: `{ check: { modifier, dc, advantage? }, onSuccess?, onFailure? }` (`onSuccess`/`onFailure` default to `invoke` when omitted). `DialogueBox` rolls via `@jgengine/core/stats/rollCheck`'s `rollCheck({ modifier, dc, advantage }, rng?)` (d20 by default; `advantage`/`disadvantage` roll twice and take the high/low; a natural 1 or max-die result reports `critical`) when the player clicks a checked choice, then calls `onChoice(choice, result)`; game code resolves which command to run with `resolveDialogueInvoke(choice, result)` (also exported from `@jgengine/react/components`).
2064
+ ### Design-resolution fit (`platforms` + `hudFit`)
1510
2065
 
1511
- ## `scene.entity.stats` — bounded stats
2066
+ Design-resolution fit is on by default for every game: each `HudCanvas` auto-scales from `hudFit.designSize` (default 1600×900) down to the live viewport, clamped by `hudFit.minScale`/`maxScale` (default 0.4–1), so the authored layout shrinks instead of overflowing a phone. No declaration needed. `hudFit.mobile` overrides the fit on compact displays only tune the phone presentation there instead of hand-rolling media queries. The player's Graphics → UI scale setting multiplies the computed scale on every platform. Declaring `platforms: ["web"]` (without `"mobile"`) opts a desktop-only game out; its compact displays keep the legacy fixed 0.85 zoom.
1512
2067
 
1513
- ```ts
1514
- stats.get(instanceId, statId) // → { current, max, min } | null
1515
- stats.set(instanceId, statId, { current?, max?, min? })
1516
- stats.delta(instanceId, statId, n) // → null | { reason } — clamps into [min, max]
1517
- ```
2068
+ **Overflow is an error, not a style note.** `HudCanvas` measures every `HudPanel` against the viewport at runtime; offenders land in a `data-hud-overflow` attribute (and a console warning), and `bun run shoot <game> --device mobile` (or `both`) exits non-zero naming the escaping panels. A game is not mobile-done while shoot reports HUD OVERFLOW.
1518
2069
 
1519
- Health, mana, xp, level, energy — any stat id declared on the catalog. Spawn seeds from the catalog (`current ?? max`). Combat writes through effects; non-combat (regen ticks, XP grants) calls `delta` directly.
2070
+ ## 5. Change the visual grammar
1520
2071
 
1521
- **XP/level use the engine progression primitive.** `@jgengine/core/game/progression` ships `curve()`/`evalCurve()` (evaluate a game-owned XP-per-level curve *definition*) and `leveling()` (a level track over the bounded `xp`/`level` stats that reports overflow). You own the curve *numbers* in a catalog; the engine owns the overflow math — on level-up bump `level.current`, reset `xp.max` from the curve, push a `stat.levelUp` feed entry. Hand-rolling `xpForLevel`/`levelFromXp`/`xpToNextLevel` is the anti-pattern — those already exist. `LevelingConfig.thresholdMode` picks how the curve is read: `"perLevel"` (default) treats `xpForLevel(N)` as the incremental N-1→N cost, summed internally; `"cumulative"` treats `xpForLevel(N)` as the total lifetime XP to reach level N (0 at/below `startLevel`) and compares `xp.current` straight against those totals — pick this for a design that quotes "total XP to level N" tables.
2072
+ Avoid making every element the same rounded translucent rectangle.
1522
2073
 
1523
- `leveling({ …, thresholdMode: "cumulative" })` switches to lifetime-total semantics: `xp` is a running total instead of a per-level bank, and `resolve(level, xp)` walks upward from `level` to the highest level whose cumulative threshold `xp` clears — it never demotes, and clamps the returned `xp` to the max-level threshold once capped. Default is `"perLevel"` (unchanged, fully back-compat) — pick `"cumulative"` for a total-XP display (MMO-style "12,450 XP") instead of a resettable per-level bank.
2074
+ Use genre-appropriate structures:
1524
2075
 
1525
- `ctx.player.stats` is a different thing: **modifiers** (buffs, ADS zoom, walk-speed bonuses) via `base/add/remove/get` with expiries — never bounded current/max values.
2076
+ - clipped corners
2077
+ - irregular silhouettes
2078
+ - image-backed frames
2079
+ - mechanical plates
2080
+ - radial interfaces
2081
+ - ribbons and tabs
2082
+ - gauges and meters
2083
+ - emblems and decorative corners
2084
+ - notches and edge anchors
2085
+ - diegetic objects
2086
+ - world-space prompts
2087
+ - asymmetrical compositions
2088
+ - masks, textures, layered borders, and strong focal elements
1526
2089
 
1527
- ## Targeting (MMO tab-target)
2090
+ Practical rule: no more than roughly 20% of a normal gameplay screen should resemble an ordinary web card or modal.
1528
2091
 
1529
- Persistent per-entity session state never a per-use input field.
2092
+ Every persistent panel must justify why it exists, remains visible, has that shape, and occupies that position.
1530
2093
 
1531
- ```ts
1532
- ctx.scene.entity.setTarget(fromId, toId | null)
1533
- ctx.scene.entity.getTarget(fromId) // → instanceId | null
1534
- ctx.scene.entity.cycleTarget(fromId, { filter: "hostile" | "friendly" | "any", direction? })
1535
- ```
2094
+ ## 6. Game UI primitives
1536
2095
 
1537
- Hostility comes from catalog `role` (`"enemy"`/`"hostile"` classify hostile). Input `tabTarget`/`clearTarget` actions route here. Handlers always read `getTarget(input.from)` — `ItemUseInput` deliberately has **no `to` field** (single source of truth, no client-supplied target to validate, three targeting models stay clean: aim for shooters, `queryArc` for melee, `getTarget` for MMO).
2096
+ Prefer small headless or lightly styled primitives over a giant universal design system. Useful concepts include:
1538
2097
 
1539
- ## `item.use` — one verb for all usable items
2098
+ - `HudAnchor`
2099
+ - `StatReadout`
2100
+ - `Meter`
2101
+ - `ObjectiveTracker`
2102
+ - `ActionPrompt`
2103
+ - `Reticle`
2104
+ - `MinimapFrame`
2105
+ - `DialoguePlate`
2106
+ - `Countdown`
2107
+ - `BossBar`
2108
+ - `ItemPickup`
2109
+ - `DamageIndicator`
2110
+ - `PauseScreen`
2111
+ - `ResultsScreen`
2112
+ - `VirtualControlZone`
2113
+ - `ScreenTransition`
1540
2114
 
1541
- ```ts
1542
- ctx.item.use.register(handlers) // once in onInit; duplicate names throw
1543
- ctx.item.use.can(ctx, input) // → { reason } | null
1544
- ctx.item.use.use(ctx, input) // dispatches catalog `use` → your handler
1545
-
1546
- type ItemUseInput = { from: string; itemId: string; inventoryId?: string; aim?: Aim };
1547
- type ItemUseHandler<GameContext> = {
1548
- can?(ctx, input): { reason: string } | null;
1549
- apply(ctx, input): { state: GameContext; error?: string };
1550
- };
1551
- ```
2115
+ A primitive should expose game-oriented choices such as shape, material, urgency, placement, hierarchy, entry motion, icon treatment, compactness, and diegetic-versus-overlay presentation.
1552
2116
 
1553
- **Handlers receive the full `GameContext` as state** and mutate through it. Handlers own ammo, cooldowns, range checks, and effect ids; the engine owns projectile geometry, stat clamp math, and `canReceive`.
2117
+ Do not create a primitive whose only value is wrapping a `div` with border radius.
1554
2118
 
1555
- | Handler | Engine calls |
1556
- |---------|--------------|
1557
- | gun | spend ammo → `fireProjectile` → `settleProjectile` |
1558
- | grenade | `fireProjectile` (ballistic) → settle → `effect({ at, radius })` |
1559
- | melee | `queryArc` + reach from `getStat` → `effect` per hit |
1560
- | MMO cast | `getTarget(from)` → `stats.delta(mana)` → `effect({ to })` |
1561
- | consumable | `effect({ to: from, effect: "heal", via: { amount: -n } })` |
2119
+ ## 7. Complete interaction states
1562
2120
 
1563
- Banned in the engine: `weapon.fire`, `consumable.use`, `game.combat.*`, per-weapon commands.
2121
+ Every interactive element needs intentional states:
1564
2122
 
1565
- ### Skill-checks and QTE (timed/rolled minigames)
2123
+ - rest
2124
+ - hover where applicable
2125
+ - keyboard/controller focus
2126
+ - pressed
2127
+ - selected
2128
+ - disabled
2129
+ - success
2130
+ - failure
2131
+ - warning
1566
2132
 
1567
- `@jgengine/core/interaction/skillCheck` models a moving-target-zone minigame (casting/reeling, active-reload): `evaluateSkillCheck({ trackWidth, zone, markerPeriod, window, zoneDriftPerSecond? }, elapsedSeconds)` bounces a marker back and forth over `markerPeriod` seconds and returns `{ success, timedOut, markerPosition, zone }` — `zone` itself can drift when `zoneDriftPerSecond` is set. It is pure: an `item.use` handler starts a session by recording `ctx.time.now()` (game-time, so pause/fast-forward apply for free) the first time it's pressed, and evaluates `evaluateSkillCheck` against the elapsed time on the next press to lock in success/fail — the session bookkeeping (a `Map<instanceId, startedAt>`) is game-owned, same pattern as an ability-cooldown map.
2133
+ Do not communicate all states with background-color changes alone. Use appropriate combinations of:
1568
2134
 
1569
- `@jgengine/core/interaction/qte` sequences discrete timed prompts: `evaluateQteSequence(steps: QteStep[], inputs: QteInputEvent[])` walks `{ id, action, windowStart, windowEnd }` steps against `{ action, at }` presses and returns `{ status: "success" }` or `{ status: "fail", atStep, reason }`; `pendingQteStep`/`qteProgress` read the currently-active step and fraction complete for UI.
2135
+ - scale compression
2136
+ - position shift
2137
+ - edge or glow response
2138
+ - mask movement
2139
+ - icon movement
2140
+ - text response
2141
+ - brief particles
2142
+ - sound
2143
+ - haptics when supported
2144
+ - controlled shake only when appropriate
1570
2145
 
1571
- `@jgengine/react` ships matching headless UI: `SkillCheckBar({ config, startedAt })` and `QteTrack({ steps, startedAt })` self-tick via `requestAnimationFrame` and read `ctx.time.now()` each frame pass `className`/`trackClassName`/`zoneClassName`/`markerClassName` (or `stepClassName`/`activeClassName`/`doneClassName` for `QteTrack`) for the moving-zone/timing visuals the UI quality bar requires.
2146
+ Focus must look authored while remaining accessible. Menus should support keyboard/controller-style focus navigation when practical.
1572
2147
 
1573
- ### Capture and owned roster
2148
+ ## Settings menu
1574
2149
 
1575
- `@jgengine/core/scene/captureCheck` `captureChance({ hpFraction, catchPower, difficulty? })` returns a 0..1 probability (lower `hpFraction` and higher `catchPower` raise it, higher `difficulty` lowers it); `rollCapture(input, rng?)` rolls it. `@jgengine/core/scene/roster` `createRoster()` is a persisted, per-owner store (`capture`, `release`, `list`, `get`, `setEquipped`, `equippedList`, `snapshot`/`hydrate`) wired onto the runtime as `ctx.game.roster`, distinct from `game.social.party` (session-ephemeral) roster entries persist and are optionally equipped (deployed) independent of party membership.
2150
+ **Settings menu (themed, four layouts, no forced chrome).** The engine builds the whole menu for free — Sound (master + per-bus volume), Graphics (quality/dpr + shadows), Gameplay (FOV slider, default 40–120), Controls (per-action key rebinding, inline click-to-rebind, persisted) from the game's `audio.buses` and `input` map. What it does **not** do is bolt a fixed gear onto every game: **there is no auto trigger.** You place the entry yourself so it lives *inline with your game's own UI*, never a stray corner overlay. Drop `<SettingsTrigger className=…>` (from `@jgengine/react`) anywhere in your HUD or menu — headless button, `className` for skin/placement, optional `children` to replace the default gear glyph, renders nothing when there's nothing to show. Or call `useSettings().open()` from your own control. Tune the menu via `defineGame({ settings })` (`GameSettingsConfig` from `@jgengine/core/settings/settingsModel`):
1576
2151
 
1577
- A capture item's `item.use` handler composes the primitives instead of forking them: read the wild target's hp via `ctx.scene.entity.stats.get(target, "health")`, roll `rollCapture({ hpFraction, catchPower })`, and on success call `ctx.scene.entity.despawn(target)` + `ctx.game.roster.capture(ownerId, catalogId)` the wild scene entity is removed and re-parented into the owner's persisted roster; the react `CaptureOdds({ chance })` component shows the live odds meter the UI quality bar requires.
2152
+ - `variant: "panel" | "sheet" | "sidebar" | "fullscreen"` the layout + skin (default `panel`; `sheet` is the mobile bottom-sheet). All four are fixed-size (no shrink-to-content jitter) and read the game's `--jg-*` theme tokens, falling back to a neutral dark skin.
2153
+ - `actions: SettingsActionDef[]` — game-state actions (Restart, Quit to menu, …). They become the **first "Game" tab, shown before anything else** — the home for buttons that used to float over the HUD. Each: `{ id, label, kind?: "default"|"danger", description?, run(ctx) }`; the menu closes right after `run`.
2154
+ - `hideBindings: string[]` — input actions to drop from the rebindable Controls list. A game-state key like `restart` belongs in `actions`, not the rebind grid — hide it here so it stops showing up as a "rebindable" control.
2155
+ - `surface: "quick"` — additionally mount compact on-screen volume/graphics buttons. Omit for none. `settings: false` — off entirely.
2156
+ - `extra: GameSettingDef[]` — append rows to any category, built-in *or a brand-new one* named by `category` (any string). Each row: `{ id, label, category, kind: "slider"|"toggle"|"select", default, min?, max?, step?, options?, onChange?(value, ctx) }`.
2157
+ - `categories: SettingCategoryDef[]` — declare custom category tabs, or relabel/reorder built-ins (`{ id, label, order? }`).
2158
+ - `hide: SettingCategory[]` — drop built-in categories.
1578
2159
 
1579
- ## Combateffects, projectiles, death, feel, abilities
2160
+ **Game-state controls go in `actions`, never a floating button.** Restart/quit/new-game buttons stapled to the bottom of the HUD are the anti-pattern declare them as `actions` (first Game tab) and place a `<SettingsTrigger>` inline. A contextual button on a win/lose *results* card is fine; a persistent game-state button pinned over live play is not.
1580
2161
 
1581
- Combat primitiveseffects & projectiles, death handling, melee/defense/telegraph feel, and abilities/resources/auto-target/resistance/run drafts. Full surface: **[reference/combat.md](reference/combat.md)**.
2162
+ **Present it any way you want.** `useSettings()` (`@jgengine/react`) returns the live controller `{ categories, actions, variant, surface, isOpen, open, close, setOpen }` — so a game can drive its own pause-menu button, or render `categories`/`actions` (rows carry `value`/`set`/bounds, keybinds carry `rebind`/`reset`) entirely inside its own HUD. `useHasSettings()` gates a custom entry; `useSetting(id, fallback)` reads/writes one value. Set a slider's `min`/`max` explicitly — an omitted range collapses the thumb to 0/1.
1582
2163
 
1583
- ## Loot
2164
+ ## 8. Motion and game feel
1584
2165
 
1585
- ```ts
1586
- lootTable({ id, rolls?, entries: [{ item? | currency?, count: n | [min,max], weight }] })
1587
- ctx.game.loot.register(table) // in onInit
1588
- ctx.game.loot.has(id) / roll(id, rng?) / grantToPlayer(userId, drops, source?)
1589
- ```
2166
+ Add purposeful motion for:
1590
2167
 
1591
- Tables colocate with their domain (`entities/enemies/loot-tables.ts`, `objects/loot-tables.ts`). Entities reference them via `onDeath.drops`; chests via a `loot.open` command arg. `grantToPlayer` fills declared inventories, grants currencies, and emits `loot.granted`.
2168
+ - screen entry and exit
2169
+ - confirm and cancel
2170
+ - warnings
2171
+ - score increases
2172
+ - objective updates
2173
+ - damage
2174
+ - victory and failure
2175
+ - countdowns
2176
+ - pause
2177
+ - item pickup
1592
2178
 
1593
- ## Card, board & shaped-inventory primitives
1594
- Pure, renderer-free structures for card, board, and deckbuilder games — they sit **beside** the slot inventory, not in place of it. All are immutable-reducer + thin-controller pairs, mirroring the two-tier ctx/factory model: use the `create*` controller in game code, reach for the exported pure functions (`draw`, `moveCards`, `tickTimeline`, `laneAggregate`, `runPipeline`, `placeShaped`) for unit tests and headless servers.
1595
- ```ts
1596
- // cards/cardPile — named ordered zones (deck/hand/discard/exhaust); seeded shuffle, hand limit, reshuffle-on-empty
1597
- const pile = ctx.game.cards.pile("deck", { zones: ["deck","hand","discard","exhaust"], drawFrom:"deck", handZone:"hand", discardTo:"discard", handLimit:7, reshuffleFrom:"discard" });
1598
- pile.reset(createCardPileState(pileConfig, { deck: ids })); // seed zone contents once, from onInit
1599
- pile.shuffle("deck", seed); // seeded Fisher–Yates via pileRng — deterministic under the same seed
1600
- pile.draw(5); // deck → hand, clamped to handLimit, reshuffles discard when deck runs dry
1601
- pile.discard(ids); pile.exhaust(ids, "exhaust"); // Slay the Spire / Balatro lifecycle
1602
- // cards/modifierPipeline — ordered { source, apply(value) → value } with an inspectable per-step trace
1603
- const score = runPipeline({ chips: 10, mult: 1 }, jokers); // score.value + score.trace[i].{before,after,changed} for Balatro-style scoring readouts
1604
- // board/laneBoard — N lanes, per-side power aggregate + optional per-lane LaneRule modifier (Marvel Snap / Inscryption)
1605
- board.aggregate(lane, "player").total; board.outcome(lane).winner; board.lanesWon();
1606
- // board/timelineBoard — N slots each on an independent cooldown, resolving in expiry order (The Bazaar auto-battlers)
1607
- board.tick(dtMs); // → fires[] sorted by expiry time then slot index; multiple fires per slot per tick
1608
- // inventory/shapedGrid — polyomino footprints, rotate, overlap-check, adjacency (Backpack Hero / Tetris inventory)
1609
- placeShaped(grid, { id, value, footprint }, [col,row], rotation); // rotateFootprint / canPlace guard overlap + bounds
1610
- gridAdjacencyQuery(grid).neighborsOf(id); // feeds synergy effects
1611
- ```
1612
- Reuse the engine's seeded RNG (`pileRng`) for anything random — never `Math.random()` in game logic. The React drag/rotate/drop/snap gesture layer over these lives in `@jgengine/react` (see UI section).
1613
-
1614
- `ctx.game.cards.pile(id, config?)` is the runtime-wired accessor for `createCardPile`: lazily creates the pile on first call (`config` required then) or returns the existing one for `id` on every later call, and every mutation notifies `ctx.subscribe`/bumps `ctx.version()` — so a `useEngineState`-bound hand/discard view re-renders without a game-owned store. Reach for `createCardPile` directly only for a headless test or server; game code goes through `ctx.game.cards.pile`.
1615
-
1616
- ## Puzzle primitives — cell grids and falling pieces
1617
-
1618
- Two pure, renderer-free `@jgengine/core` primitives for cell-based puzzle games (Tetris wells, match-3 boards); tile art and the drop-cadence loop are the shell's/game's job.
1619
-
1620
- - **`puzzle/cellGrid`** — a generic immutable `CellGrid<T>` for uniform typed-cell boards. Row 0 is the top; `y` grows downward. `createCellGrid`, `cellAt`, `withCell`/`withCells` (immutable single/batch writes), `fullRows`/`clearRows` (line-clear + compaction), `collapseColumns` (match-3 cascade gravity), `findRuns` (run detection with an optional custom matcher).
1621
- - **`puzzle/fallingPiece`** — the falling-piece layer over a `CellGrid`: `ShapeTable<TShape>` maps rotation states to cell offsets; `pieceCells`/`pieceCollides`/`mergePiece` place, test, and commit a piece; `dropDistance` computes the ghost-piece landing row; `gravityInterval`/`levelForLines`/`lineScore` are the classic Tetris drop-speed/level/score curves (overridable); `createLockDelay`/`stepLockDelay` is the grounded→countdown→lock stepper (`delaySeconds: 0` locks instantly on touchdown).
1622
- - **`tactics/fallingGrid`** — a generic tile-drop grid over any `TCell` payload (distinct from the `cellGrid`/`fallingPiece` row-clear pair): `createFallingGrid(config)`, `gravityIntervalMs(level, config?)` for the drop-speed curve, and a `FallingGridSnapshot`/`LockState` shape for the grounded→lock stepper.
1623
-
1624
- ## Dropped items — `worldItem` and the loot filter
1625
- A `worldItem` is a scene **entity** (position + item ref + rarity), never an inventory item or object — see the three buckets. `onDeath.dropMode: "world"` (above) is the usual producer; games can also hand-place ground loot (chests, quest drops).
1626
- ctx.scene.worldItem.spawn({ itemId, position, rarity?, baseType?, count?, affixTier?, source? })
1627
- ctx.scene.worldItem.get(instanceId) / list() / nearestInRadius(from, radius, filter?)
1628
- ctx.scene.worldItem.pickup(instanceId, userId) // grants to inventory + despawns, emits worldItem.picked_up
1629
- Click-to-grab is engine-owned: setting `pointer.grabWorldItems: true` in `defineGame({...})` makes `@jgengine/shell`'s `GamePlayerShell` resolve `pointer.worldHit()` on primary click, and — when the hit entity is a `worldItem` within the `worldItem.pickupRadius` (default `DEFAULT_PICKUP_RADIUS`) configured on `defineGame({...})` of the local player — calls `pickup` directly, no game command needed. `@jgengine/react`'s `useWorldItems()` / `useNearestWorldItem(radius)` drive a HUD pickup prompt off the same store.
1630
- Presentation is a two-layer render binding, both engine-owned (rendered by `@jgengine/shell`'s `WorldItems`) over **game-supplied data**:
1631
- 1. **Rarity baseline** — the `worldItem.rarityStyle: Record<rarity, { color?, beam?, label? }>` field of `defineGame({...})`, the game's rarity palette (Borderlands/Diablo-style beam + color coding).
1632
- 2. **Loot filter overlay** (#33) — the `worldItem.filter: LootFilterRule[]` field of `defineGame({...})`, built with `lootFilter([{ id, when: { rarity?, baseType?, minAffixTier?, maxAffixTier? }, hide?, color?, beam?, label? }])` from `game/lootFilter`. **First matching rule wins** (PoE/Last Epoch block semantics); a rule only overrides the fields it sets, everything else falls back to the rarity baseline. `resolveWorldItemPresentation(item, rarityStyle, rules)` composes both layers and is what the shell calls per item.
1633
- ## Gear systems — durability, affixes, modular items, storage tiers
1634
- Four pure primitives that hang off item **instances** (not the stackable catalog id) — all catalog-first (specs are game-supplied config) and renderer-free. Item instances that carry durability/affix/modular state key off a game-assigned instance id, the same way targeting keys off entity instance ids.
1635
- **Durability** (`item/durability`) — per-instance wear + repair. `DurabilitySpec` (`{ max, wearPerUse?, wearPerHit?, disableAtZero?, repair? }`) is catalog data; `createDurability(spec)` seeds a `DurabilityState`, `wear(spec, state, "use" | "hit", times?)` decrements (floors at 0), `isDisabled(spec, state)` gates use at zero, `durabilityFraction` feeds a HUD bar. Repair is quote-then-apply: `repairQuote(spec, state, { station?, to? })` returns the `{ item, count }[]` material cost (scaled by points restored) + the post-repair state (optional `qualityLossPerRepair` shrinks `max` each repair, Tarkov-style) — the game charges the materials through inventory, then commits the quote's `state`. `createDurabilityTracker()` keeps `DurabilityState` per instance id for the runtime.
1636
- **Affix roller** (`item/affix`) — procgen `base × rarity → { rolled affixes, computed stats, name }`. `createAffixRoller({ pools, rarities })` over rarity-weighted `AffixPool`s. `roll(base, rarityId, rng)` draws `affixCount` distinct affixes without replacement (weighted, via the engine's `pickWeighted`), computes stats (base × `rarity.statScale`, then `op: "add"` affixes, then `op: "mul"`), and composes a name from `rarity.namePart` + prefix/suffix parts. `rollRarity(rng)` picks a weighted tier; `rollRandom(base, rng)` chains both. Pass `seededRng(seed)` for deterministic drops; any `() => number` rng works (same contract as `loot.roll`). `seededRng` lives in `random/rng` (re-exported here) alongside `seededStreams(seed)`, which derives independent named streams from one seed — `streams("worldgen")` vs `streams("history")` — so simulation draws never perturb generation (intervening in a run cannot change the map).
1637
- **Modular item** (`item/modularItem`) — a whole assembled from parts in typed mount slots (guns, mechs). `ModularItemDef` has `slots: MountSlotDef[]` (`{ id, accepts, required? }`); `install(def, installed, slotId, part)` validates the slot exists, accepts the part's `category`, and is empty; `computeEffectiveStats(def, installed)` rolls part `stats` (additive) then `multipliers` over `baseStats`; `missingRequiredSlots`/`isComplete` gate a buildable whole. `createModularItem(def)` is the stateful wrapper (`install`/`uninstall`/`effectiveStats`/`partInSlot`).
1638
- **Storage tiers + insurance** (`inventory/storageTier`) — the extraction-economy inventory half. Inventory containers carry a `tier: "carried" | "banked"` (`InventoryDeclaration.tier`; a Tarkov secure container is just a `banked` container on the body). `partitionOnDeath(containers)` splits a death snapshot into `{ kept, lost }` (banked survives, carried is dropped, stacks merged). `createDeliveryQueue()` is the delayed-delivery (insurance) hook: `schedule` a `ScheduledDelivery` with a game-time `deliverAt`, then `due(now)` / `claimDue(now)` drain it on the tick clock. `insureLost(lost, policy, userId, now, rng?)` filters the lost set to insured items and stamps a delayed `deliverAt` → feed straight into the queue. `resolveConsolation(policy, partition)` returns a baseline loadout id (apply via `applyLoadout`) — the death consolation grant, optionally gated on `if-carried-empty`. *(Session/round machines — extraction hold-to-leave, raid banking — consume this tier; see the objective-machine group.)*
1639
- ## Objective, round & session machines
1640
- Content-agnostic state machines for competitive/session shapes — plant/defuse, buy/live/end rounds, downed/revive, the battle-royale ring, extraction raids, run-vs-meta persistence. All pure `core`; every timer takes a **game-time** `dt`/`now` (`ctx.time`), so pause and fast-forward apply for free. Drive them from `loop.onTick` and pipe their events into `ctx.game.feed`/`events`; render their snapshots as HUD (per the UI quality bar in [`reference/ui-react.md`](reference/ui-react.md) — the downed banner, ring warning, and extraction timer are required HUD).
1641
- **Contested channel** (`session/contestedChannel`) — the interrupt-on-damage progress objective behind plant/defuse, cash-out, urn deposit, banishing, and hold-to-extract. `createContestedChannel({ duration, interruptOnDamage?, resetOnInterrupt?, favorability?, ratePerOccupant?, contested?, decayRate? })`: `start(team)` begins the channel, `tick(dt, occupants)` advances it against per-team occupancy (`Record<teamId, count>`) and emits `start`/`tick`/`contested`/`paused`/`complete` events, `damage(reason?)` interrupts (keeps or zeroes progress per `resetOnInterrupt`). `favorability[team]` scales fill rate (Deadlock deposit); `ratePerOccupant` fills faster with more owners present; `contested: "pause" | "decay"` chooses whether an opposing occupant freezes or reverses progress (The Finals contest). The owner leaving pauses it. Extraction hold-to-leave reuses this primitive verbatim.
1642
- **Round state** (`session/roundState`) — the buy→live→end match machine (Valorant/CS). `createRoundState({ phases, teams, phaseOrder?, winCondition?, maxRounds?, winReward?, lossBonus? })`: `tick(dt)` runs the phase timer and auto-advances (emitting `phase.start`/`phase.end`, rolling the last phase back into the next round's first), `concludeRound(winner)` records the win on any "conclude-eligible" phase (any phase but the first/last in the cycle), settles `round.economy` (winner gets `winReward`, losers get an escalating `lossBonus` via `lossBonusFor(rule, streak)` clamped to `max`), and moves to the next phase. `onPhaseEnd(hook)` fires commerce/spawn gates on each transition; `match.end` fires at `maxRounds`. `server.mode` stays a game string — this is the timer/economy engine under it.
1643
-
1644
- Two extras beyond the default buy/live/end cycle: `phaseOrder?: string[]` overrides the phase names/cycle entirely (a wider `Record<string, number>` `phases` shape to match) — a draft→ban→play→score cycle is the same machine with different phase names. `teams: (string | { id, role? })[]` accepts a plain id or a `{ id, role }` pair; `roleOf(team)` reads the tag back (`"attacker"`/`"defender"`, Valorant side assignment) without a parallel lookup table. `winCondition?: (snapshot: RoundSnapshot) => string | null` lets `evaluate()` (call it from `onTick` alongside `tick(dt)`) auto-conclude the round the instant a score/objective condition is met, instead of the game hand-calling `concludeRound` — return a team id to end it, `null` to keep playing; `RoundSnapshot` is `{ round, phase, timeLeft, scores, lossStreaks, roles, matchOver }`.
1645
- **Role assignment** (`session/roles`) — `assignRoles(players, specs: RoleSpec[])` distributes fixed-count or proportional roles (hider/seeker, spy/operative, prop/hunter) across a player list — the allocation half of an asymmetric session mode; `RoundConfig.teams`' per-team `role` is the lighter-weight alternative when a round machine already tracks the roster.
1646
- **Downed / revive** (`combat/downed`) — the 3-state alive→downed→dead chain (Apex/Helldivers). `createDownedState({ bleedoutSeconds, reviveSeconds?, reviveHealthFraction?, banner? })`: `down(id)` starts the bleedout, `tick(dt)` counts it down (→ `died`, optionally spawning a `banner`), `revive(id, dt)` accumulates an ally's hold time (→ `revived` with the health fraction the game restores), `finish(id)` executes a downed enemy, and `respawnFromBanner(id)` brings a banner-holder back at a beacon. It sits **in front of** the engine death resolution: on lethal damage call `down` instead of dying; on `died`/`bleedout` run the real `resolveDeath`. No banner ⇒ death is terminal.
1647
- **Shrinking ring** (`session/ring`) — the battle-royale safe zone with out-of-bounds DoT. A catalog `RingConfig` is `{ center, phases: RingPhase[] }` where each phase is `{ startTime, shrinkDuration, fromRadius, toRadius, damagePerSecond, center? }` on the game clock. `ringSampleAt(config, t)` / `createRing(config).at(t)` returns the live `{ center, radius, damagePerSecond, shrinking }` (radius/center interpolate during each shrink window, hold between phases); `isOutside(t, pos)` / `distanceOutside(t, pos)` test a point, and `damageOutside(t, dt, positions)` returns per-entity `{ id, damage }` for everyone beyond the wall — feed those into `scene.entity.stats.delta`/`effect` each tick.
1648
- **Extraction session** (`session/extraction`) — the raid-scoped "reach an extract and leave to bank what you carried" wrapper (Tarkov/DMZ/Helldivers), composed from the contested channel + `inventory/storageTier`. `createRaidSession({ extracts, insurance?, consolation? })`: `beginExtract(userId, extractId, team?)` opens a hold-to-leave channel, `tickExtract`/`damage` drive it, and on completion `resolveExtraction(userId, containers)` banks everything carried. `resolveDeath(userId, containers, now, rng?)` runs `partitionOnDeath` (banked kept, carried lost), schedules insured items through the built-in delivery queue (`claimDeliveries(now)` drains it on the clock), and yields the consolation loadout id. `playerSnapshot(userId)` feeds the extraction-timer HUD.
1649
- **Persistence scopes** (`runtime/persistenceScope`) — the run-vs-meta split with explicit reset boundaries (Icarus mission wipe, Once Human season reset). `partitionScopes(state, { run })` splits a flat record into `{ meta, run }` by key; `resetRun` clears the run half while meta (talents/blueprints/account currency) survives; `clearRunFields(playerRow, runFields)` and `applyRunReset(profile, runFields, now)` do the same over `RuntimePlayerRow`/`PlayerProfileRecord`. `planScenarioReset({ gameId, serverId?, wipeChunks?, wipeServerSession?, resetPlayers?, runFields? })` normalizes a scenario/season reset that `HostPersistence.resetScenario?(reset)` applies — `@jgengine/sql` implements it (deletes the server's chunks + session, run-resets each profile in one transaction), keeping account meta intact.
1650
-
1651
- ## Trade
1652
-
1653
- Catalog `trade` fields drive everything — no duplicate price lists.
2179
+ Motion should be brief, readable, interruptible when necessary, coordinated, consistent with the game’s art direction, and respectful of reduced-motion settings.
1654
2180
 
1655
- ```ts
1656
- ctx.game.trade.canBuy(itemId, shopId, count?) // → reason | null
1657
- ctx.game.trade.canSell(itemId, count?)
1658
- ctx.game.trade.buy(itemId, count, { shop, inventoryId }) // charge → put, rolls back on failure
1659
- ctx.game.trade.sell(itemId, count, { shop, inventoryId })
1660
- ctx.game.trade.tradableAt(shopId, allItemIds) // derive stock from catalogs
1661
- ```
2181
+ Do not animate everything constantly. Motion communicates hierarchy, cause and effect, urgency, and state changes.
1662
2182
 
1663
- ## Economy and unlocks
2183
+ ## 9. Mobile controls are genre-authored
1664
2184
 
1665
- ```ts
1666
- ctx.game.economy.balance(userId, currencyId) / grant(...) / charge(...) // charge → { reason } | null
1667
- ctx.game.unlocks.has(userId, id) / grant(userId, id) / list(userId) / tree(categoryId)
1668
- ```
2185
+ Shared input mechanics may remain shared. Their visual treatment and arrangement must match the game.
1669
2186
 
1670
- Catalog `requires: [unlockId]` gates validate at command time.
2187
+ Examples:
1671
2188
 
1672
- ## Crafting, tech tree & production
2189
+ **Driving**
2190
+ Steering region or wheel, accelerator, brake, handbrake, optional camera/map control.
1673
2191
 
1674
- Four **pure** primitives (no ctx, no renderer) for survival-crafting, tech-tree, factory, and farming games. All are catalog-first: recipes, tech nodes, production rates, and crop stages are game **data** you feed the primitive — the engine owns the graph math, the timers ride `ctx.time` (game-seconds), never wall-clock.
2192
+ **Stealth**
2193
+ Movement zone, sneak/crouch hold, contextual interaction, temporary map/schedule control.
1675
2194
 
1676
- **Recipe graph** — `@jgengine/core/crafting/recipe`. A `RecipeDef` is `{ id, inputs: RecipeItem[], outputs: RecipeItem[], seconds?, station?, stationRange?, requires? }` — inputs + optional required-workstation-in-range + time → outputs. `craft(state, layout, traits, recipe, context)` consumes inputs and produces outputs on an `InventoryState` **atomically** (rejects `missing-inputs` / `no-station` / `locked` / `no-output-space` without mutating on failure); `canCraft(...)` is the dry-run. `context = { origin?, stations?, unlocked? }`: `stationSatisfied` checks a matching placed workstation (`{ catalogId, position }`) within `stationRange` of `origin`, and `requires` gates on `unlocked(id)` (wire it to `ctx.game.unlocks.has` or the tech tree). `createRecipeGraph(defs)` indexes recipes by `producing(itemId)` / `using(itemId)` / `category`. Long crafts schedule completion with `ctx.time.after(craftSeconds(recipe), …)`.
2195
+ **Shooter**
2196
+ Movement zone, aim region, fire/action cluster, weapon or ability controls.
1677
2197
 
1678
- **Tech tree** — `@jgengine/core/economy/techTree`. **Generalizes flat `unlocks`, does not duplicate it**: a `TechNodeDef extends UnlockDef` adds `requires` (prerequisite node/unlock ids), an optional `recipe` payload, and `grants` (extra flat unlock ids). A node id **is** an unlock id, so flat unlocks are just tech nodes with no `requires`. `createTechTree(defs)` wraps `createUnlocks` internally and gates grants on prerequisites: `unlock(userId, id)` refuses until every `requires` is met, `available(userId)` is the reachable frontier, `recipes(userId)` lists the recipe payloads a player has unlocked (feed them to the recipe graph). `tree(categoryId)` and per-user `has`/`list`/`snapshot`/`hydrate` mirror `unlocks`.
2198
+ **Puzzle/arcade**
2199
+ Direct drag, tap, swipe, paddle region, or discrete directions. Do not add a joystick without a gameplay reason.
1679
2200
 
1680
- **Production building** — `@jgengine/core/crafting/production`. `productionBuilding({ id, inputs, outputs, rate, power?, bufferMultiplier? })` — a placed building that consumes buffered inputs and emits outputs on a timer. `rate` is production **cycles per game-second**; `tickProduction(def, state, { dt, powered? })` advances continuously through `dt` (so pause/fast-forward apply for free) and completes as many cycles as the buffer allows. `feedProduction` / `drainOutput` move items in and out of the internal buffers (a puller/conveyor). `advanceTransport(path, items, dt)` slides items along a belt and splits off `delivered`. `resolvePowerGrid(supply, consumers)` powers demands greedily until supply is exhausted — gate a building's tick on `powered`.
2201
+ Requirements:
1681
2202
 
1682
- **Farming** — `@jgengine/core/crafting/crop`. `CropTileState` is a soil state machine (`untilled` → `tilled` → planted); `tillTile` / `plantCrop` / `waterTile` are pure tile transitions and `advanceCropDay(def, tile)` runs the **day tick** — a `CropDef { stages, regrowDays?, needsDailyWater?, harvest? }` advances a growth stage per watered day and sets `harvestable`; `harvestCrop` yields and either clears the tile or resets a regrow crop. `applyToolToTiles(tiles, center, pattern, apply)` applies a tool across a tile pattern under the cursor — `singleTile()`, `squarePattern(r)`, `diamondPattern(r)`, `rectPattern(w,d)` (watering-can / hoe AoE). `createCropField(catalog)` is the stateful wrapper over a tile grid (`till`/`plant`/`water`/`harvest`/`advanceDay`); drive `advanceDay()` off the calendar day rolling over — `createDayTicker(startDay)` reports how many days `ctx.time.calendar().day` has crossed.
2203
+ - never cover critical HUD information
2204
+ - use the game’s shape and material language
2205
+ - fade training labels after learning
2206
+ - consider thumb reach
2207
+ - preserve accessible target sizes
2208
+ - visually respond to activation
2209
+ - support optional scaling where appropriate
1683
2210
 
1684
- ## `applyLoadout`
2211
+ Do not use one generic translucent controller across all games.
1685
2212
 
1686
- ```ts
1687
- ctx.player.loadout.register(loadouts) // onInit
1688
- ctx.player.applyLoadout(userId, loadoutId) // → null | { reason }
1689
- ```
2213
+ ## 10. Progressive instruction
1690
2214
 
1691
- `LoadoutDef = { inventories?: { hotbar: [{ item, count, slot? }], … }, stats?, economy?, unlocks? }`. Application is **all-or-nothing**: every inventory put dry-runs first; any rejection applies nothing. Starter kits gate on `ctx.player.isNew`; class/respawn kits run from commands. Never scatter raw `put`/`grant` calls for a kit.
2215
+ Do not leave large control grids visible during gameplay.
1692
2216
 
1693
- ## Quests
2217
+ Prefer:
1694
2218
 
1695
- ```ts
1696
- ctx.game.quest.register(catalog) // onInit
1697
- canAccept / accept / abandon / canTurnIn / turnIn / grant / revoke
1698
- progress(userId, questId, objectiveId, delta)
1699
- list(userId) / has(questId)
1700
- bind("entity.died") // kill objectives match objective.target === catalogId
1701
- bind("inventory.added") // collect objectives match objective.item
1702
- ```
2219
+ - contextual prompts
2220
+ - brief onboarding
2221
+ - first-use hints
2222
+ - a controls screen
2223
+ - pause-menu reference
2224
+ - icons attached to actions
2225
+ - progressive disclosure
1703
2226
 
1704
- Catalog: `{ id, title, giver?, turnIn?, requires?, objectives: [{ id, kind, target?/item?, count, partyShare? }], rewards? }`. `requires` is satisfied by a completed quest of that id or an unlock. `turnIn` applies declarative `QuestRewards` `{ xp?: { amount }, economy?: Record<string, number>, items?: { item, count, inventory }[], unlocks?: string[], quests?: string[] }` — note `xp` takes an `{ amount }` wrapper (applied via `stats.delta` + your level-up loop) and each reward `item` names the `inventory` it fills; chained `quests` are auto-offered if acceptable. Events: `quest.accepted` / `quest.updated` / `quest.completed`. `partyShare: { radius, credit: "all" | "tagger" }` extends kill credit to nearby party members.
2227
+ Desktop keyboard legends must not appear on touch devices. Prompts should appear near the relevant action, object, or HUD region and clear when no longer useful.
1705
2228
 
1706
- ## Social
2229
+ ## 11. Reference directions for flagship games
1707
2230
 
1708
- ```ts
1709
- ctx.game.social.friends.canRequest / request / accept / decline / remove / block / list / requestsFor // persisted
1710
- ctx.game.social.party.register({ maxMembers }) // then canInvite / invite / accept / decline / kick / leave / promote / list / membersOf / invitesFor
1711
- ctx.game.social.presence.get(userId) // { online, serverId?, zoneId?, instanceId? }
1712
- ctx.game.social.emotes.play(fromUserId, emoteId, radius?) // → { from, emoteId, at, recipients } | { reason }
1713
- ctx.game.social.worldInvites.invite(fromUserId, toUserId, { serverId, joinCode? }) // then canInvite / accept / decline / listFor
1714
- ```
2231
+ ### Clockwork Heist
1715
2232
 
1716
- Party is ephemeral session state (invites expire; leader leaving promotes the next member). Events: `social.friend.added`, `social.party.joined`, `social.party.left`.
2233
+ Use gentleman-thief mechanical field-kit language: watch geometry, midnight enamel, aged brass, ivory paper, engraved labels, mechanical shutters, and an authored schedule timeline. The timer should feel like a clock. The schedule should be an on-demand pocket-watch or dossier panel. Touch controls should use brass/enamel construction. Restart belongs in pause, not permanently over the world.
1717
2234
 
1718
- **World invites** bridge friends and `multiplayer/matchmaking`: an invite carries the `{ serverId, joinCode? }` of the session you're in (the same fields as a `SessionListing`); `accept(userId, inviteId)` → `{ target }` is the join target you hand to your backend's `joinServer`/`joinByCode` — the invite never joins anything itself. Invites are ephemeral like party invites (TTL via `SocialDeps.worldInviteTtlMs`, default 60s; blocked users can't invite either direction). Events: `social.world.invited`, `social.world.accepted`. React: `useWorldInvites()` lists pending invites for the local player.
2235
+ ### Canyon Chase
1719
2236
 
1720
- `emotes.play` reuses `scene.entity.inRadius` to find nearby **player**-role entities (default radius 20) and emits `emote.played` never build a parallel proximity broadcast. Emote ids are game-defined strings (no registration, same convention as effect ids). Bind it into the existing feed primitive for a HUD feed: `ctx.game.feed.bind("emote.played")` + `useFeed({ action: "emote.played" })` no dedicated emote hook exists or is needed.
2237
+ Use desert pursuit language: battered dashboard, analog instruments, radio display, route strip, warning lamps, and road-sign typography. Target gap should read as a pursuit gauge. Border progress should resemble an odometer, route strip, or mile marker. Driving controls should feel like steering, throttle, brake, and handbrake—not generic circles.
1721
2238
 
1722
- ## Chat
2239
+ ### Brick Breaker
1723
2240
 
1724
- ```ts
1725
- ctx.game.chat.send(fromUserId, channelId, body) // → { message, recipients } | { reason }
1726
- ctx.game.chat.whisper(fromUserId, toUserId, body) // stable per-pair channel "whisper:<a>:<b>"
1727
- ctx.game.chat.history(channelId, { limit?, viewerUserId? }) // viewer filter drops blocked senders
1728
- ctx.game.chat.register({ id, kind, radius?, historyLimit?, rateLimit? }) // custom channels
1729
- ctx.game.chat.channels() / snapshot() / hydrate(data)
1730
- ```
2241
+ Use arcade-cabinet language: bezel framing, CRT/vector treatment, arcade numerical readouts, attract mode, launch feedback, and brief level overlays that clear before play. Pause and results should feel like cabinet states, not web dialogs.
1731
2242
 
1732
- Built-in channels: `global` (everyone), `party` (reuses `social.party.membersOf`; rejects "not in a party"), `proximity` (reuses the same spatial/entity seam as emotes, default radius 20, **player**-role entities only). `kind` picks the recipient resolution; custom channels pick one of the three kinds. Sends are trimmed, capped (500 chars), and rate-limited per user per channel (default 10/10s, sliding window — `createChatRateLimiter` is the reusable pure primitive). Mute rides social's blocked set: blocked pairs can't whisper, and blocked senders are dropped from party/proximity recipients and from `history` when `viewerUserId` is passed. Every send emits `chat.message` (recipients omitted = broadcast). History is a bounded ring per channel (default 100) with `snapshot`/`hydrate` like `Friends`.
2243
+ These games must remain structurally distinct, not recolors of one component set.
1733
2244
 
1734
- **Remote chat seam** (`multiplayer/chatContract`): `ChatTransport` is the hook-shaped contract (`useMessages(channelId | "skip")` / `useActions()`, identity-stable like `PresenceTransport`); `ChatSync` is the callback shape for backends that can't host React hooks. Bindings: ws — `createWsBackend(...).chatSync` / `.chatSyncFor(serverId)` over `chatSend` frames + a `chat` update channel (host relays per-channel rings, validates length + rate limit); Convex — `@jgengine/convex/convexChatTransport` `createConvexChatTransport({ messages, sendMessage })` (one live query + one mutation); local/dev — `createLocalChatTransport()`. React lifts a `ChatSync` via `chatTransportFromSync`.
2245
+ ## 12. Accessibility and performance
1735
2246
 
1736
- ## Cosmetic loadout
2247
+ Maintain:
1737
2248
 
1738
- ```ts
1739
- ctx.player.cosmetics.register(defs) // onInit Record<loadoutId, { slots: Record<slot, cosmeticId> }>
1740
- ctx.player.cosmetics.apply(userId, loadoutId) // merges the preset's slots
1741
- ctx.player.cosmetics.equip(userId, slot, cosmeticId | null) // set/clear one slot directly
1742
- ctx.player.cosmetics.get(userId) // Record<slot, cosmeticId>
1743
- ```
2249
+ - sufficient contrast
2250
+ - readable text size
2251
+ - keyboard navigation
2252
+ - controller navigation where available
2253
+ - reduced-motion support
2254
+ - visible authored focus
2255
+ - touch target sizing
2256
+ - semantic labels where practical
2257
+ - responsive scaling
2258
+ - reasonable DOM and animation performance
1744
2259
 
1745
- A per-player appearance layer distinct from `applyLoadout` (which grants inventory/stats/economy/unlocks) — cosmetics never touch gameplay state, only equipped slot ids for your renderer to read. Emits `cosmetics.changed`.
2260
+ Use blur, masks, textures, filters, and full-screen effects carefully, especially on mobile.
1746
2261
 
1747
- ## Possession
2262
+ ## 13. Screenshot verification is mandatory
1748
2263
 
1749
- ```ts
1750
- ctx.player.possession.own(userId, entityId) / disown / owns / listOwned(userId)
1751
- ctx.player.possession.active(userId) // → entityId, defaults to userId itself
1752
- ctx.player.possession.possess(userId, entityId) // → null | { reason } — must be owned + spawned
1753
- ```
2264
+ Capture and inspect meaningful states:
1754
2265
 
1755
- A player can own N scene entities (party members, vehicles, a possessed creature) and control exactly one at a time — distinct from `game.social.party`, which is a social grouping, not a control model. `possess` flips the previous/next entity's scene `EntityRole` between `"player"`/`"npc"` and emits `possession.swapped`; `@jgengine/shell`'s `GamePlayerShell` reads `active(userId)` every frame to rebind WASD movement, tab-targeting, hotbar `from`, and the camera rig's `followEntityId` to whichever entity is currently controlled — a game never wires this rebind itself.
2266
+ - desktop title screen
2267
+ - desktop gameplay
2268
+ - mobile landscape
2269
+ - mobile portrait where supported
2270
+ - pause
2271
+ - victory or failure
2272
+ - an interaction prompt
2273
+ - touch controls in active use
1756
2274
 
1757
- ## Form / shapeshift
2275
+ Inspect for:
1758
2276
 
1759
- ```ts
1760
- ctx.scene.entity.form.register(defs) // onInit — FormDef[] = { id, movement?, abilities?, model? }
1761
- ctx.scene.entity.form.shapeshift(instanceId, formId, durationSeconds?) // → null | { reason }
1762
- ctx.scene.entity.form.active(instanceId) // formId | null
1763
- ctx.scene.entity.form.abilities(instanceId) // readonly string[] | null
1764
- ctx.scene.entity.form.revert(instanceId) // early revert
1765
- ```
2277
+ - overlap and clipping
2278
+ - weak contrast
2279
+ - unreadable scale
2280
+ - excessive cards
2281
+ - website-like composition
2282
+ - broken safe areas
2283
+ - conflicting hierarchy
2284
+ - browser chrome interference
2285
+ - poor thumb reach
2286
+ - keyboard instructions on touch devices
2287
+ - inconsistent art direction
1766
2288
 
1767
- A `form` bundles movement params + an ability-id list + a mesh into one swappable unit (shapeshift/transformation — V Rising bear/wolf/bat, Wukong's boss transformation). `model` reuses the entity's catalog `name` (the same key `entityModels`/`entitySprites` resolve against), so the mesh swap rides the existing render lookup — no parallel mesh field. `durationSeconds` is **game time**: it schedules the automatic revert through `ctx.time.after`, so it obeys pause and fast-forward like everything else on the clock. Emits `form.changed`.
2289
+ Revise after inspection. Typechecking is not visual proof.
1768
2290
 
1769
- ## Events, feed, leaderboard
2291
+ ## 14. Rejection criteria
1770
2292
 
1771
- ```ts
1772
- ctx.game.events.on(name, handler) // register in onInit; typed GameEventMap
1773
- ctx.game.feed.bind(action) // pipe an engine event into a ring buffer (default 20)
1774
- ctx.game.feed.push(action, entry) // manual channels (chat, crafting)
1775
- ctx.game.feed.recent(action, { limit? })
1776
- ctx.game.leaderboard.track({ stat, scope: "global" | "server" | "profile" }) // onInit
1777
- ctx.game.leaderboard.increment(userId, stat, { scope, by? }) / getTop / getProfile
1778
- ```
2293
+ Require revision when any of these are true:
1779
2294
 
1780
- `ctx.game.commands.define(name, { validate?(ctx, input), apply(ctx, input) })` registers a verb (`has`/`names`/`run` round it out); `run(name, input)` returns `{ status: "applied", state } | { status: "rejected", reason } | { status: "unknown-command" }`. `apply` may either **return** the next state (the classic reducer shape) or mutate `ctx` in place and return **nothing** — `run` keeps the current `ctx` as `state` when `apply` returns `void`, so a handler that only calls other `ctx` methods (spawn, effect, loot.grantToPlayer, …) doesn't need a pointless `return ctx`. **Event handlers use `ctx` directly** the same way (side effects: leaderboard, economy, scheduling) and never reassign state. One feed primitive for kill feeds, loot logs, quest updates — no per-domain feed hooks.
2295
+ - normal site navigation remains visible during active gameplay
2296
+ - the player must scroll the page to use the game
2297
+ - the game is presented inside a normal content card
2298
+ - essential HUD is covered by touch controls
2299
+ - controls overlap each other
2300
+ - keyboard instructions appear on touch devices
2301
+ - large instruction panels remain visible during gameplay
2302
+ - more than three ordinary card-like panels are persistently visible
2303
+ - the main action looks like a standard website button
2304
+ - pause resembles a generic website modal
2305
+ - victory/failure is only text plus restart
2306
+ - restart is permanently visible without a gameplay reason
2307
+ - menu elements lack pressed, focused, selected, or disabled states
2308
+ - UI changes have no transition or feedback
2309
+ - generic virtual controls are used without genre adaptation
2310
+ - the theme only changes colors and fonts
2311
+ - unrelated UI elements have equal visual weight
2312
+ - mobile portrait technically fits but is not intentionally composed
2313
+ - important UI sits beneath safe areas
2314
+ - ordinary document flow determines the HUD layout
2315
+ - generic default styling is used because no art direction was written
1781
2316
 
1782
- ## `ctx.game.store` reactive game state
2317
+ ## 15. Compact implementation API appendix
1783
2318
 
1784
- ```ts
1785
- ctx.game.store.set("health", 100) // any key, any value type
1786
- ctx.game.store.get("health") // T | undefined
1787
- ctx.game.store.has("health")
1788
- ctx.game.store.delete("health")
1789
- ctx.game.store.subscribe(listener) // change-signal fires on set/delete
1790
- ctx.game.store.mapSnapshot() / arraySnapshot()
1791
- ```
2319
+ Use the main `jgengine` skill for the authoritative engine API routing. The React package exposes `GameProvider`, hooks, and headless primitives from `@jgengine/react` and its documented subpaths. Common hooks include player/game state, entities, stats, inventory, quests, prompts, clocks, markers, fog, and engine stores. The shell provides `GamePlayerShell`, input integration, devtools, and `GameUiPreview`.
1792
2320
 
1793
- A reactive per-game keyed store (`ObservableKeyedStore<unknown>`) attached to `GameContext` — reach for it instead of a module-level singleton store for ad-hoc reactive game state (turn trackers, deck UIs, anything that doesn't already have a `ctx` surface). `set`/`delete` bump `ctx.version()` and notify `ctx.subscribe` listeners; `get`/`has` are plain reads. Unlike a per-slot handle, there is no `define`/seed step a key simply doesn't exist until the first `set`.
2321
+ Use these APIs to bind state; do not let API wiring dictate visual composition. Keybind labels should derive from the game’s binding table, and UI actions should dispatch through game commands rather than existing only as click handlers.
1794
2322
 
1795
- ## `ctx.game.cards` / `ctx.game.turn` lazily-created piles and turn loops
2323
+ Headless components are not finished design. They are behavior and accessibility seams that the game must art-direct.
1796
2324
 
1797
- `ctx.game.cards.pile(id, config?)` and `ctx.game.turn.loop(id, config?)` lazily create (config required on first call) or return the existing notify-wrapped `CardPile`/`TurnLoop` for `id` — call with just the id after the first `onInit` seed to fetch the same instance; every mutating method is wrapped so it bumps `ctx.version()`/notifies `ctx.subscribe` automatically, same as every other `ctx` surface. This replaces manually constructing `createCardPile`/`createTurnLoop` and wiring notification yourself.
2325
+ ## Definition of done
2326
+
2327
+ UI work is complete only when:
2328
+
2329
+ - the game owns the viewport
2330
+ - site chrome is absent during active play
2331
+ - no document scrolling is required
2332
+ - HUD and control layers have clear responsibilities
2333
+ - mobile controls do not cover critical content
2334
+ - touch controls match the genre and game identity
2335
+ - title, pause, and results screens feel authored
2336
+ - interaction states and transitions are present
2337
+ - screenshots have been reviewed and revised
2338
+ - the implementation remains accessible and performant
2339
+ - future generated UI is explicitly prevented from falling back to generic website-card styling
2340
+
2341
+ ---
2342
+ name: jgengine-world
2343
+ description: World API: movement, cameras, physics, maps, sensors, spawn placement.
2344
+ ---
2345
+
2346
+ # jgengine-world
1798
2347
 
1799
2348
  ## Movement, pose, input
1800
2349
 
@@ -1833,7 +2382,7 @@ movement: {
1833
2382
 
1834
2383
  **Vertical motion intents** — `ctx.player.motion` (`@jgengine/core/runtime/motionIntents`): `impulse(vy)` adds to the vertical velocity the shell's controller is about to integrate, `setVerticalVelocity(vy)` replaces it outright, `setY(y)` wins over physics for that frame. The shell calls `takePending()` once per frame, before integrating gravity, to drain what accumulated; this is not reactive state (jump pads, launch abilities, bounce pads).
1835
2384
 
1836
- **`collision: { voxel: true }` — object lattice as solids.** When set, the shell's local player uses a voxel body whose `isSolid(x,y,z)` is rebuilt from `ctx.scene.object.list()` as exact `` `${x},${y},${z}` `` keys (integer cell queries). Integer-placed objects are walkable/blocking; fractional-coord objects decorate without colliding. Removing an object opens a real trapdoor under gravity — do not fake the fall with `setPose`. `visual.scale` does not shrink the collider (always a unit cell). The voxel body is created once; prefer `motion.setY` / `impulse` for vertical relocation — full XY teleport of the local voxel body is not supported. Solid cache rebuilds when object **count** changes. Recipe: `jgengine-newgame` → voxel trapdoor board.
2385
+ **`collision: { voxel: true }` — object lattice as solids.** When set, the shell's local player uses a voxel body whose `isSolid(x,y,z)` is rebuilt from `ctx.scene.object.list()` as exact `` `${x},${y},${z}` `` keys (integer cell queries). Integer-placed objects are walkable/blocking; fractional-coord objects decorate without colliding. Removing an object opens a real trapdoor under gravity — do not fake the fall with `setPose`. `visual.scale` does not shrink the collider (always a unit cell). The voxel body is created once; prefer `motion.setY` / `impulse` for vertical relocation — full XY teleport of the local voxel body is not supported. Solid cache rebuilds when object **count** changes. Recipe: `jgengine` → voxel trapdoor board.
1837
2386
 
1838
2387
  **Respawn** — `ctx.scene.entity.spawnPoseOf(id)` reads the spawn pose, `resetToSpawn(id)` teleports back to it with zero velocity, `resetAllToSpawn(filter?)` does it for every entity matching an optional filter and returns the count (round resets, out-of-bounds recovery).
1839
2388
 
@@ -1865,7 +2414,7 @@ touch: {
1865
2414
  - **`look` / `lookSensitivity`** — drag-to-look on the play surface; defaults to `true` for `first`-person camera rigs, `0.005` radians/px.
1866
2415
  - **`touch: false`** — opt out entirely when the game's own DOM UI is already touch-native.
1867
2416
 
1868
- `useDisplayProfile()` (`@jgengine/react/display`) reports `{ coarsePointer, compact, portrait }` — live media-query state, SSR-safe — for adaptive HUD layout; see the mobile/touch rules in [`reference/ui-react.md`](reference/ui-react.md).
2417
+ `useDisplayProfile()` (`@jgengine/react/display`) reports `{ coarsePointer, compact, portrait }` — live media-query state, SSR-safe — for adaptive HUD layout; see the mobile/touch rules in [`../jgengine-ui/reference.md`](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-ui/reference.md).
1869
2418
 
1870
2419
  ## Interaction — `proximityPrompt`
1871
2420
 
@@ -1884,10 +2433,11 @@ The **pointer is a service, not per-game glue**. Opt in with `camera` plus a `po
1884
2433
  ## AI — director, threat, jobs, crowds (`ai/*`)
1885
2434
  Renderer-free AI over the same navmesh (`findPath`/`pathFollow`) gameplay already uses. Everything ticks on **game-time `dt`** (the `ctx.time` simClock delta), so it obeys pause and fast-forward for free. Manifests, patrol routes, job definitions, threat weights, and POIs are **game data** — the primitives own the loop, the catalog owns the content.
1886
2435
  - **Spawn director** (`ai/spawnDirector`) — budgets and escalates spawns for wave shooters and difficulty directors (Brotato, Bloons TD 6, Risk of Rain 2, Helldivers 2, Deep Rock Galactic). `createSpawnDirectorState(config)` then pure `advanceSpawnDirector(config, state, dt, { alive, players? })` → `{ state, spawns: SpawnRequest[] }`. Each `WaveManifest` grants a `budget` spent on affordable weighted `SpawnEntry`s (`cost`/`weight`/`minWave`), capped by `maxAlive`; `duration` auto-advances waves (or call `advanceWave` on "wave cleared"). Budget also trickles via `budgetPerSecond`, ramps a difficulty curve with `escalationPerSecond` (grows with sim-time), scales with `playerBudgetPerSecond`, and surges on `raiseAlert(state, amount)` decaying over time (bug-breach/dropship escalation). Seeded (`seed`) so ticks are deterministic. `pickSpawnPoint(points, players, { roll, bias })` biases placement toward (or away from) players. `config.spawnPoints?: NavPoint[]` lets the director pick a point itself: each `SpawnRequest` then also carries `point` (the chosen `[x, z]`, biased by `config.spawnPointBias`) and `laneId` (the point's index into `spawnPoints`) — feed multiple named lanes/portals and read `laneId` back to route the spawned entity down its lane instead of correlating positions by hand.
1887
- - **Threat table** (`ai/threat`) — MMO/extraction aggro (Escape from Tarkov, WoW-style tanking). `createThreatTable({ decayPerSecond?, max?, forgetBelow? })`: `add(source, amount)` accumulates, `decay(dt)` bleeds off per game-second and forgets emptied sources, `highest({ current?, stickiness? })` returns the top-threat source to feed `scene/targeting` — `stickiness` (e.g. 1.1) keeps the current target until another exceeds it by that factor, so aggro doesn't jitter. `ranked()` for a threat meter.
2436
+ - **Threat table** (`ai/threat`) — MMO/extraction aggro (Escape from Tarkov, WoW-style tanking). `createThreatTable({ decayPerSecond?, max?, forgetBelow? })`: `add(source, amount)` accumulates, `decay(dt)` bleeds off per game-second and forgets emptied sources, `highest({ current?, stickiness? })` returns the top-threat source to feed `scene/targeting` — `stickiness` (e.g. 1.1) keeps the current target until another exceeds it by that factor, so aggro doesn't jitter. `taunt(source, durationSeconds)` forces the mob onto a tank for that window (overriding `highest`) and lifts the taunter's threat to the current top so aggro stays after the window; `forcedTarget()` reports the active taunt, `decay(dt)` counts it down. `ranked()` for a threat meter.
1888
2437
  - **Patrol** (`scene/behaviors`) — `patrol({ waypoints, speed, loop? })` is a `BehaviorDescriptor` (a route is data) that layers a fixed beat on top of `wander`; drive it with `createPathFollow`/`advancePathFollow` (lane creeps, scav patrols in Deadlock/Tarkov). Route waypoints between guard posts with `findPath`.
1889
2438
  - **Job board** (`ai/jobBoard`) — colony/companion task assignment (Palworld stations, Schedule I employees, Sons of the Forest directives). `createJobBoard()`: `post(job)` a `JobDef` (`station`, `work` seconds, `priority`, `arriveRadius`, `repeat`), `claim(worker)` auto-pulls the highest-priority queued job or `assign(worker, jobId)` for a player order (steals it from its holder), `release` requeues. Per tick `advance(worker, dt, { distanceToStation })` runs the state machine `travelling → working → done` (path to `station(worker)` via `findPath`, occupy, run the loop), returning a `JobReport` on completion; `repeat` jobs re-run as a production loop and report each cycle.
1890
2439
  - **Crowd flow** (`ai/crowd`) — many agents routing to their own points of interest with congestion (Two Point Museum corridors, Dave the Diver seating). `computeFlowField(grid, goals, { clearance?, congestion? })` runs Dijkstra from the goals over the walkable grid → `direction(point)`/`next(point)` steer any agent toward the nearest goal (no per-agent A*). `createCrowdField(grid)` tracks per-cell occupancy (`enter`/`leave`/`count`); pass `crowd.penalty(weight)` as the field's `congestion` to reroute flow around crowded cells each tick. `selectPoi(pois, from, { roll, occupancy?, distanceBias?, distance? })` weights a POI by appeal and proximity, skips ones at `capacity`, and accepts a `distance` override (e.g. `findPath` length) to choose over the navmesh, not line-of-sight.
2440
+ - **Factions & reputation** (`faction/factions`, `faction/reputation`) — allegiance-based hostility for RPGs/MMOs/strategy (WoW-style Horde-vs-Alliance, at-war reputation grinds, guards-vs-thieves). `createFactionGraph({ factions, symmetric?, unaligned? })` models a faction-vs-faction relation matrix — each `FactionDef` lists `relations` toward others (`"hostile" | "neutral" | "friendly"`) with `towardSelf`/`towardOthers` fallbacks; `relationBetween(a, b)` resolves it (mirroring undeclared directions when `symmetric`, defaulting `unaligned` for unknowns). `createFactionRoster(graph)` maps entities to factions (`assign`/`factionOf`/`members`), then `relationBetweenEntities`/`isHostile`/`hostilesOf(observer, candidates)` answer "who is my enemy" as a data lookup — feed it into `scene/targeting`'s `classify` and `ai/threat` eligibility instead of a per-game role-string check. `createReputationLedger({ tiers?, initial?, min?, max? })` is the per-actor standing ledger: `gain`/`set`/`standing` track reputation, `tier`/`relation` map it through `DEFAULT_REPUTATION_TIERS` (the classic Hated→Exalted ladder, or your own), `standings(actor)` snapshots all. `effectiveRelation({ base, ledger, actorId, factionId })` overlays a player's earned standing on the base faction relation, so a reputation grind flips a normally-hostile faction to neutral/friendly.
1891
2441
  ## Map, fog of war & ping
1892
2442
  Minimap/world-map/fog/compass state is renderer-free core (`world/*`), the top-down terrain image bakes in the shell, and the minimap/compass/world-map are react components. Ping rides the existing party + feed — it is not a new channel.
1893
2443
  - **Markers** (`world/markers`): `createMarkerSet()` is a reactive keyed set of `MapMarker { id, kind, position, label?, owner?, expiresAt?, meta? }` — `add`/`remove`/`get`/`list`/`query({ kind, owner, near, radius })`/`prune(now)`/`subscribe`. `kind` is a game-owned catalog string; `DEFAULT_MARKER_KINDS` (objective/enemy/loot/location/danger/ping/player/ally) supplies colors + glyphs the react map reads (override with your own `MarkerKindStyle` palette). Objective/entity/loot markers all live here.
@@ -1911,377 +2461,17 @@ Shell wiring: `@jgengine/shell/vision/RevealVision` (`RevealHighlights` — dept
1911
2461
 
1912
2462
  ## World features
1913
2463
 
1914
- Renderer-free world surface — query primitives, environment fields + weather + realm composition, survival meters/moodles, interactive building & terraform, the optional headless physics world, vehicles/mounts/racing, and spawn placement. Full surface: **[reference/world.md](reference/world.md)**.
2464
+ Renderer-free world surface — query primitives, environment fields + weather + realm composition, survival meters/moodles, interactive building & terraform, the optional headless physics world, vehicles/mounts/racing, and spawn placement. Full surface: **[reference.md](https://github.com/Noisemaker111/jgengine/blob/main/.claude/skills/jgengine-world/reference.md)**.
1915
2465
 
1916
2466
  ## Turn-based & tactics (renderer-free)
1917
2467
 
1918
- Pure-`core` primitives for turn-based, grid-tactics, and card games — every one is a stateful factory with matching pure math, and every stateful piece exposes `capture()`/`restore()` so it plugs straight into the snapshot store. Overlays and tile art are the shell's/game's job; these ship the logic.
1919
-
1920
- - **`turn/turnLoop` — `createTurnLoop(config)`.** An initiative machine over an ordered participant list with optional `phases` and per-turn action-economy `pools`. `advanceTurn()` walks the order (round++ on wrap) and **resets the entering participant's pools**; `advancePhase()` steps phases then rolls into the next turn. Pools are catalog data (`{ id, max, start? }`) — a single Slay-the-Spire energy pool or BG3's Action/Bonus/Movement/Reaction set, spent independently via `spend/canSpend/gain/refill`. `setOrder`/`addParticipant`/`removeParticipant` re-roll initiative without losing the active pointer. `config.onTurnStart?(participantId)`/`onTurnEnd?(participantId)` fire on every `advanceTurn()` transition (start also fires once for the initial participant at construction) — hang status-effect ticks, "your turn" banners, or AI-turn kickoff here instead of diffing `state()` between ticks yourself. `ctx.game.turn.loop(id, config?)` is the runtime-wired accessor: lazily creates (config required the first call) or returns the existing notify-wrapped loop for `id`, so a HUD bound to `ctx.subscribe` re-renders on every turn/phase/pool change with no separate store to wire.
1921
- - **`turn/commit` — `createCommitController({ mode })`**, also hosted at `turnLoop.commit`. Three commit modes: `immediate` (submit resolves now), `simultaneous` (sealed hidden submissions → `reveal()` once `allReady()`, deterministic order — Marvel Snap), and `rewind` (visible `pending()` → `rewind()` to discard or `commit()` to finalize).
1922
- - **`turn/intent` — `createIntentBoard()`.** A minimal per-participant "what will you do" board, lighter than a full commit round: `declare(participantId, { kind, magnitude?, targetId?, note? })` records one intent per participant (overwriting any prior undeclared one), `peek(participantId)` reads without clearing, `all()` lists every declared `[participantId, intent]` pair (for an enemy-intent HUD row, Slay-the-Spire style), `consume(participantId)` reads and clears in one call, `clear(participantId?)` clears one or everyone. Reach for this when you need visible declared-but-not-yet-resolved intents (telegraphed enemy actions) without the full simultaneous-reveal machinery of `turn/commit`.
1923
- - **`tactics/tacticalGrid` — `createTacticalGrid({ width, height, blocked?, diagonal?, world? })`.** Tile occupancy (one unit per tile), `reachable(from, budget)` flood-fill (respects walls + occupants), `path(from, to)` shortest route, and `push(id, dir, { distance, chain })` discrete knockback-to-tile — chained collisions transfer momentum through struck units (Into the Breach), or stop with a recorded `PushCollision` against `wall`/`edge`/another unit. `world: { origin: [x, z], tileSize }` (mirroring `navGrid`'s bounds+cellSize convention) turns on `worldToTile(x, z)` (world point → `Tile | null`, null outside the grid) and `tileToWorld(tile)` (a tile's world-space center) — the render/pointer-hit round trip between the tactics grid and the 3D scene; omit `world` for a grid used purely as abstract logic (both throw if called without it).
1924
- - **`tactics/predictiveQuery` — `predictAreaEffect`/`predictArcEffect`/`predictTiles`.** A "would-this-effect-hit" query for pre-commit overlays and enemy-intent telegraphs. It reuses the **exact** AoE/LoS targeting behind `ctx.scene.entity.effect` (`combat/effects` `resolveAreaTargets`) so the predicted target set matches what the effect would actually drain — without committing any state change.
1925
- - **`tactics/snapshot` — `createSnapshotStore()`.** Cheap, repeatable turn-undo: `register(id, slice)` any `capture()/restore()` slice (the grid, surfaces, and turn loop all qualify), then `capture()/restore()` a deep-cloned snapshot or use the `push()/pop()` undo stack. `deepClone` handles objects/arrays/Map/Set so a held snapshot is immune to later mutation.
1926
- - **`tactics/surface` — `createSurfaceLayer({ kinds, reactions })`.** A stateful tile surface layer with its own `tick(dt)` (timed surfaces decay + expire) and a **combination matrix** — `reactions` is data (`{ when: [a, b], result }`), so grease+fire→fire and water+lightning→electrified are catalog entries, not hard-coded. Distinct from terrain/water; drive its tick from `onTick`'s game-time `dt`.
1927
-
1928
- ## External data — `data/dataSource` and the dev proxy
1929
-
1930
- Renderer-free async-state primitives (`@jgengine/core/data`) for a game that reads a live external source (a leaderboard API, a session browser, remote config) — distinct from `ctx.game.store`/multiplayer, which are for the game's own authoritative state.
1931
-
1932
- - **`createDataSource(load, options?)`** (`data/dataSource`) → `DataSource<T>` wraps one `load(signal)` async call as `{ status: "idle"|"loading"|"ready"|"error", data, error }`. `getState()` reads the current snapshot, `subscribe(listener)` fires on every change, `refresh({ force? })` re-runs `load` (de-duplicates a call already in flight unless `force`; aborts the prior call first when forced), `startPolling(intervalMs?)`/`stopPolling()` run `refresh` on an interval (`intervalMs` falls back to the one passed at construction; throws if neither is given), `dispose()` tears down polling and in-flight requests for good. Pass `options.clock` (`{ setInterval, clearInterval }`) to swap the timer source in tests.
1933
- - **`fetchJson<T>(url, options?)`** (`data/fetchJson`) — `fetch` + JSON-parse in one call; throws `HttpStatusError` (`status`, `statusText`, `url`) on a non-OK response and `JsonParseError` (`url`, `cause`) on unparsable JSON, so a `DataSource`'s `error` is always one of these two typed shapes, never a bare `Error`. `options.fetchImpl` swaps the fetch implementation for tests/SSR.
1934
- - **`createJsonDataSource<T>(url, options?)`** (`data/jsonDataSource`) — sugar combining the two above: a `DataSource<T>` whose `load` calls `fetchJson(url, options)`.
1935
- - **Dev proxy (`data/devProxy`)** — same-origin routing for external APIs during `bun dev` so browser CORS never blocks a game's `fetchJson` call against a third-party host. `parseDevProxyTable(raw)` parses a `VITE_JGENGINE_DEV_PROXY` env value (a JSON object of `{ routeName: "https://api.example.com" }`) into a `DevProxyTable`; `proxiedUrl(target, { dev?, table?, prefix? })` rewrites a `target` URL whose prefix matches a table entry into `/proxy/<routeName>/<rest>` (default prefix `/proxy`) when `dev` is true (defaults to `import.meta.env.DEV`) — else returns `target` unchanged, so the same call hits the real host in production. `apps/dev`'s `vite.config.ts` reads the same env var and wires a matching Vite server `proxy` entry per route (`changeOrigin: true`, strips the `/proxy/<routeName>` prefix) — set `VITE_JGENGINE_DEV_PROXY` once and both sides (the URL rewrite and the actual proxy route) agree.
1936
-
1937
- ## Multiplayer and the backend seam
1938
-
1939
- The transport/host/persistence seam — `createWsBackend`, protocol codec, browser-safe authoritative host + router, WebRTC P2P, the Node/Convex/SQL adapters, presence, and save cadence. Full surface: **[reference/multiplayer.md](reference/multiplayer.md)**.
1940
-
1941
- ## UI — `@jgengine/react`
1942
-
1943
- The React layer — `GameProvider`, the hooks table, headless className-passthrough primitives (incl. map components), the identity/chat/voice/social/drag-layer kits, the shadcn registry install path for visual HUD components (`npx shadcn@latest add https://jgengine.com/r/<name>.json`), the screen-layout rule, and the **UI quality bar** (required, not optional polish). Full surface: **[reference/ui-react.md](reference/ui-react.md)**.
1944
-
1945
- ## Devtools — F2 overlay and tunables
1946
-
1947
- `@jgengine/shell`'s `GamePlayerShell` mounts an F2-toggled debug overlay (`shell/src/devtools/DevtoolsOverlay.tsx`) over every game automatically — `defineGame({ devtools: false })` is the only way to turn the toggle off (default `true`). Five tabs:
1948
-
1949
- | Tab | Shows |
1950
- |-----|-------|
1951
- | Perf | fps, frame/sim ms, draw calls, triangles, entity/object counts, state notifies/s, registered probes |
1952
- | Tune | every discovered tunable — checklist grouped by source file or table export name; check one to control it live |
1953
- | Logs | captured `console.log`/`info`/`warn`/`error` |
1954
- | Net | observed backend round-trip latency (fed by `instrumentLatency`) |
1955
- | Keys | the game's `ActionCodesMap` bindings |
1956
-
1957
- **Tunables are zero-annotation.** Write plain code under `Games/<id>/src/**` — no import, no wrapper — and it's discoverable:
1958
-
1959
- ```ts
1960
- // loop.ts — top-level export const, nothing else
1961
- export const GRAVITY = -22;
1962
- export const SKY_COLOR = "#87ceeb";
1963
- export const GOD_MODE = false;
1964
-
1965
- // game/content.ts — an exported flat table of numbers/booleans/colors
1966
- export const TUNING = { reach: 6, spawnRate: 0.4, fogColor: "#334455" };
1967
- ```
1968
-
1969
- The dev runner's Vite plugin, `tunableDiscoveryPlugin` (`@jgengine/core/devtools/transformTunables`, wired in `apps/dev/vite.config.ts`), rewrites each top-level `export const <number|boolean|"#hex">` literal to `export let` and binds it into the devtools registry as the module loads (`transformTunableExports` is the pure string transform underneath; `tunableModuleTable(id)` derives the table id from the file path, skipping `main.tsx` and `*.test.*`). Table exports need no transform at all — after each game module loads, the dev app calls `devtools.discover.scanModule(moduleExports)`, which walks every export's own properties for a flat plain-object table of numbers/booleans/`"#rrggbb"` strings.
1970
-
1971
- F2 → Tune tab lists every discovered entry as a checklist, grouped by source file (top-level constants) or by table export name (object tables). Checking an entry hands it a live slider/toggle/color picker; unchecking resets it to its initial value. Kind is inferred from the value: `number` → slider, `boolean` → toggle, a `"#rgb"`/`"#rrggbb"`/`"#rrggbbaa"` string → color.
1972
-
1973
- **Liveness.** An edit applies live wherever the code reads the constant/table entry at use time. A value captured once at init — passed into a function call, baked into worldgen — only picks up the new value on reload. Overrides persist in `localStorage` per game (key `jg-devtools:<game name>`) and are re-applied *before* `loop.onInit` runs, so even an init-baked constant respects its override after a refresh — *if* the read happens at or after `onInit`. A read that happens earlier than that (see below) never sees the override, reload or not.
1974
-
1975
- **Default assumption: almost every gameplay number, boolean, and color is a tunable, not a hardcoded fact.** Walk speed, jump height, gravity, damage, cooldowns, spawn rates, drop chances, radii, durations, thresholds, multipliers, colors — if it's a scalar a designer would plausibly want to nudge while playing, it belongs in a place discovery can see (a top-level `export const`, or a direct scalar field on a catalog def object like `PlayerDef`/`EnemyDef`) — never buried as a bare literal inside a deeper nested object with no named export, and never computed once and thrown away. Treat "should this be tunable" as opt-out, not opt-in.
1976
-
1977
- **Catalog-derived content must read fields live, not bake them at import time.** A common trap: a `content.ts` (or any module implementing `GameContextContent`) that loops over a catalog array *once at module scope* and copies scalar fields into a separately cached `Map`:
1978
-
1979
- ```ts
1980
- // WRONG — copies walkSpeed by value at import time, before devtools can even scan exports
1981
- const entityEntries = new Map<string, GameContextEntityEntry>();
1982
- for (const p of players) {
1983
- entityEntries.set(p.id, { stats: p.stats, movement: { poses: p.poses, walkSpeed: p.walkSpeed } });
1984
- }
1985
- export const content: GameContextContent = {
1986
- entityById: (id) => entityEntries.get(id) ?? null,
1987
- };
1988
- ```
1989
-
1990
- This runs during module import — earlier than `discoverGameTunables`/override-application in `apps/dev/src/main.tsx`, and earlier than any `loop.onInit`. The catalog object (`p`) still gets live-mutated by devtools, but nothing ever re-reads it, so the baked `walkSpeed` is permanently stale: no F2 edit and no persisted override ever reaches gameplay, reload or not.
1991
-
1992
- ```ts
1993
- // RIGHT — map ids to the catalog def itself; build the entry fresh on every lookup
1994
- const playersById = new Map(players.map((p) => [p.id, p]));
1995
- function entityById(id: string): GameContextEntityEntry | null {
1996
- const p = playersById.get(id);
1997
- return p === undefined ? null : { stats: p.stats, movement: { poses: p.poses, walkSpeed: p.walkSpeed } };
1998
- }
1999
- export const content: GameContextContent = { entityById };
2000
- ```
2001
-
2002
- Same shape, same call site — the only change is *when* `p.walkSpeed` is read: at lookup time (each spawn) instead of once at import. Objects (`stats`, `receive`) are already reference-safe to pass through either way; this only matters for scalars (numbers/booleans/strings) copied out of a catalog def.
2003
-
2004
- **`tunable()` still exists — an optional low-level primitive, not the recommended path.** Reach for it only when you need explicit bounds, an `options` select, or a change subscription that discovery can't infer:
2005
-
2006
- ```ts
2007
- import { tunable } from "@jgengine/core/devtools/devtools";
2008
-
2009
- const gravity = tunable("physics/gravity", -22, { min: -60, max: 0 });
2010
- ```
2011
-
2012
- Read `gravity.value` at use time (or `gravity.subscribe(listener)`) — never destructure once at module load. A `"group/label"` name (e.g. `"physics/gravity"`) groups the control under `group` in the Tune tab; `devtools.controls.register` is the same call underneath. Real example: `Games/voxel-mine/src/loop.ts` — `tunable("mining/reach", REACH, { min: 2, max: 16, step: 1 })`, read via a getter passed to `createEditorHandlers`.
2013
-
2014
- **Agent loop.** The overlay's "Copy report" button copies a JSON `DevtoolsSnapshot`; from a browser session an agent can instead call `window.__JG_DEVTOOLS.snapshot()` directly (or `snapshotDevtools()` from game code) for the same shape — frame stats, render sample, latency stats, captured logs, probe values, every registered control's current + initial value, and a `discovered` array (`id`, `kind`, `value`, `enabled`) covering every auto-discovered tunable whether or not it's enabled — a single call to check "is this actually working" without a screenshot. `window.__JG_DEVTOOLS.discover` is exposed directly too (`list`/`enable`/`disable`/`bind`/`scanTable`/`scanModule`/`clear`) so an agent can flip a discovered tunable on and read/write it from the console without touching the UI.
2015
-
2016
- `devtools.probes.register("name", () => value)` (`@jgengine/core/devtools/devtools`) surfaces a game-specific gauge (entity count, queue depth, whatever) in both the Perf tab and the snapshot; call the returned unregister function to remove it.
2017
-
2018
- ## Assets — real art from day one
2468
+ # jgengine domain API — World features
2019
2469
 
2020
- Squares as enemies, colored boxes as buildings, and a flat grid floor read as *broken*, not unfinished. The blueprint's **Asset plan** names the packs before the first edit; a pass does not end while any default-material primitive, unstyled ground plane, or debug grid is visible in the staged screenshot — and that includes 2D HUD art: a first-letter tile, emoji, or one generic shape reused per slot is a placeholder exactly like a graybox enemy. Primitive stand-ins are allowed only *mid-pass* as scaffolding.
2021
-
2022
- **Sources** (CC0 — public domain, commercial use, no attribution — unless noted):
2023
-
2024
- | Source | What you get |
2025
- |--------|--------------|
2026
- | [Kenney.nl](https://kenney.nl) | 40,000+ CC0 assets: characters, buildings, nature, vehicles, weapons, UI, audio — the broadest single library |
2027
- | [Quaternius](https://quaternius.com) / [KayKit](https://kaylousberg.itch.io) | CC0 low-poly packs incl. **rigged + animated characters**: medieval, sci-fi, dungeons, animals, adventurers |
2028
- | [Poly Haven](https://polyhaven.com) / [ambientCG](https://ambientcg.com) | CC0 PBR textures, HDRIs, materials — the floor comes from here, never a flat color |
2029
- | [Poly Pizza](https://poly.pizza) | Search engine over thousands of CC0 low-poly models for one specific thing |
2030
- | [Game-Icons.net](https://game-icons.net) (CC BY 3.0 — credit it) / Kenney UI packs (CC0) | 4,000+ item/ability **icon silhouettes** — the registry `game-icon` item covers common HUD glyphs first |
2031
- | [itch.io CC0 3D tag](https://itch.io/game-assets/assets-cc0/tag-3d) / [OpenGameArt](https://opengameart.org) | Long tail — **check the license per asset**, CC0 filter first |
2032
- | [Mixamo](https://www.mixamo.com) | Free humanoid animations (Adobe license — fine for shipped games, not CC0) |
2033
- | Kenney audio / [freesound CC0 filter](https://freesound.org) | Hit sounds, UI clicks, ambience |
2034
-
2035
- **Rules:**
2036
-
2037
- 1. **One style family per game.** Kenney + Quaternius + KayKit low-poly mix fine; low-poly models on photoreal PBR ground reads broken. Name the family in the blueprint.
2038
- 2. **License discipline.** CC0 needs nothing; anything else gets a line in `src/game/assets-credits.md` (source, author, license). Never ship an asset you can't name the license of.
2039
- 3. **Wire through the engine seams.** GLB models live in the game's `src/game/assets.ts` render catalog keyed by catalog id; billboards via `entitySprites`, real meshes via `entityModels`/`objectModels` in `defineGame({...})`; ground/skies belong to the world layer. Catalog `model` fields reference asset keys — never file paths in game logic. Source models through **`@jgengine/assets`** (`buildCatalog({ basePath })` → resolve ids/aliases → urls); `pull` packs into your app's `public/models/` (extracts Kenney's shared `Textures/` alongside the GLBs so models render textured). Network-restricted: `pull` falls back through `--mirror <baseUrl>` / `JGENGINE_ASSETS_MIRROR`, and `--offline` fails fast — see the package README for the fallback order and add/import flow.
2040
- 4. **Coverage follows the content budget.** Every entity family, placed object, and held item maps to a real asset *before* the catalog entry ships. If the pack lacks a model, restyle the noun to one it has — rename the fantasy, don't ship a cube.
2041
- 5. **Scale/pivot sanity.** `@jgengine/assets` measures each model's footprint/center/`minY` at reindex and ships them on the catalog entry (`catalog.resolve(id).dims`); with `objectModels` anchor `"center"` (the default) the shell centers the footprint on the placement point and ground-snaps the lowest vertex — so `object.place(id, cellX, y, cellZ, { rotation })` renders centered + grounded with no pivot math and no `dimensions.ts`. Anchor `"origin"` opts back into the raw GLB origin. Check the first placement of each model against its catalog `footprint`; one wrong pivot repeated 100 times is a rebuild.
2042
- 6. **Item and ability icons are assets too.** Every hotbar/inventory/ability slot renders a real, distinct icon — the registry `game-icon` catalog (`iconForItemId`/`iconForAction`) or a Game-Icons/Kenney silhouette — per the UI quality bar's real-icons rule.
2043
-
2044
- ## Genre cheat sheet
2045
-
2046
- - **Voxel/crafting**: objects for blocks/machines, `voxel()`, `object.break`/`object.placeFromInventory`.
2047
- - **Tycoon/lab**: objects + `slotInventory`, `plots()`, configure via prompt → command.
2048
- - **Shooter**: `fireProjectile`/`settleProjectile`; grenades settle → `effect({ at, radius })`; `movement.poses`/`aim` + zoom modifier; `servers({ … })` + game-owned `server.mode`; loadout classes from commands.
2049
- - **MMO/RPG**: bounded stats + `leveling()` over a game XP curve; `tabTarget` → `cycleTarget`; handlers read `getTarget`; quests bound to `entity.died`/`inventory.added`; social party + `partyShare`; `server: "persistent"`.
2050
- - **All combat games**: react to `entity.died` (feed/leaderboard/score) — never poll HP.
2051
-
2052
- ## Anti-patterns
2053
-
2054
- | Wrong | Right |
2055
- |-------|-------|
2056
- | Player tuning in `defineGame` | Entity catalog `movement` + stats |
2057
- | `behaviors: […]` on place/spawn | Catalog entry |
2058
- | Engine `weapon.fire` / `consumable.use` / `combat.*` | `item.use` + catalog `use` → game handler |
2059
- | `ItemUseInput.to` for targets | `getTarget(from)` in handlers |
2060
- | `effect({ to })` for gunshots | `fireProjectile` + `settleProjectile` |
2061
- | Polling HP in `onTick` for kills | `entity.died` event |
2062
- | `combat.lootTable` / `loot.enemy` | `onDeath` on the entity that died |
2063
- | Hand-rolled `Math.random()` loot in commands | `lootTable()` + `ctx.game.loot.roll` |
2064
- | Hand-rolled `xpForLevel`/`levelFromXp` | `game/progression` `curve()` + `leveling()` |
2065
- | Hardcoded shop arrays | `item.trade.shops` + `tradableAt` |
2066
- | Kit seeding via scattered `put`/`grant` | `applyLoadout` |
2067
- | Per-user quest state hand-rolled | `game.quest.register` + binds |
2068
- | `useKillFeed` / per-domain feed hooks | `useFeed({ action })` |
2069
- | Raw keys in game logic | `defineGame` input actions |
2070
- | Positioning inside `ui/components/` or on primitives (`CurrencyPill className="absolute …"`) | Screen wrappers in `GameUI.tsx` only |
2071
- | Game UI classes without `@source` in host CSS | `@source` entries for your game dirs + `node_modules/@jgengine/{shell,react}` |
2072
- | One file per catalog entry / per brand | Dense `<domain>/catalog.ts` |
2073
- | Convex mutations called from game code | `commands.run` through the `GameBackend` transport |
2074
- | Half a system: quest without tracker, cooldown without sweep, keybind never shown, stub "coming soon" modal | Finish the system end to end — or cut it whole (see `jgengine-newgame`) |
2075
- | Game-side workaround for a missing engine primitive | File the gap at github.com/Noisemaker111/jgengine/issues (or PR the primitive) and cut or scope the dependent system honestly |
2076
- | Game nouns in this skill | Engine primitives + placeholder ids only |
2077
-
2078
- ## New-game definition of done
2079
-
2080
- This is a gate, not a suggestion — every box, in one pass (workflow: **`jgengine-newgame`** skill). "Compiles and the hooks are wired" is not done; a declared system with no UI, no feedback, or no way to exercise it is not done — finish the system or cut it whole.
2081
-
2082
- - [ ] `game.config.ts` (`defineGame` from `@jgengine/shell/defineGame`) + `index.tsx` (barrel) + `main.tsx` (standalone host) + `loop.ts` + `game/content.ts`
2083
- - [ ] Catalogs: `game/entities/<role>/catalog.ts`, `game/items/<domain>/catalog.ts`, `game/objects/catalog.ts`; loot tables beside their domain
2084
- - [ ] Entity `stats` + `receive` orders aligned on the same stat ids; `role` set (drives targeting + camera)
2085
- - [ ] `game/items/use-handlers.ts` registered in `onInit`; handlers read `getTarget`/`aim`, never a target input
2086
- - [ ] `game/loadouts.ts` + `applyLoadout` in `onNewPlayer` (gated on `isNew`)
2087
- - [ ] `game/quests/catalog.ts` + binds; if using xp/level, a game-owned curve fed to `game/progression` (`curve`/`leveling`) — **with their HUD/tracker, or cut**
2088
- - [ ] `onInit`: register handlers/loadouts/loot/quests, event listeners, feed binds, leaderboard tracks; `setupWorld`
2089
- - [ ] Player spawns with `id === ctx.player.userId`
2090
- - [ ] `game/ui/GameUI.tsx` owns layout; components use `@jgengine/react` hooks
2091
- - [ ] UI passes the **quality bar** above (contrast, scale, framing, genre fit) — not just hook wiring
2092
- - [ ] Camera tuned via `camera` in `defineGame({...})` — defaults untouched means the feel was never checked
2093
- - [ ] For an `environment()` world: a `<game>.world.test.ts` asserts `summarizeEnvironment(world)` (`@jgengine/core/world/environmentSummary`) is non-empty with the expected counts — the browserless scene-correctness gate
2094
- - [ ] HUD screenshotted over a staged `GameUiPreview` scenario and **judged by looking at the image** against the UI quality bar in [`reference/ui-react.md`](reference/ui-react.md) — the final human glance, not the verification loop
2095
- - [ ] Co-located bun tests for pure game math (curves, cooldowns, spawn logic)
2096
- - [ ] Multiplayer via adapter config only; no direct backend calls
2097
-
2098
- ## Quick reference
2099
-
2100
- ```
2101
- defineGame (shell) engine fields (assets, world, physics, inventories, input, server, save, time, feed, multiplayer)
2102
- + presentation fields (content, loop, GameUI, camera, environment, shadows, movement, devtools, …) in one call — smart defaults fill the rest
2103
- defineGame (core) the underlying engine-only primitive: assets, world, physics, inventories, input, server, save, time, feed, multiplayer, loop
2104
- PlayableGame { game, content, loop, GameUI, camera, … } — the runner contract `defineGame` (shell) returns
2105
- GameContext ctx.scene / ctx.game / ctx.player / ctx.item / ctx.camera / ctx.input + subscribe/version
2106
- scene.object place, remove, move, rotate, at, setVisual (per-instance ObjectVisual: scale/color/opacity override)
2107
- scene.entity spawn (anchor/offset), despawn, setPose, update; stats; targeting; effects;
2108
- projectiles (object-aware raycasts); spatial queries (opt-in grid broadphase)
2109
- entity.stats get / set / delta — bounded stats (health, mana, xp, level) on instances
2110
- progression game/progression — curve() / leveling() over bounded xp/level stats
2111
- item.use catalog `use` → GameContext handler; no input.to
2112
- effects drain-signed magnitudes; receive.<effect>.order; AoE = effect + at/radius/los
2113
- projectiles willHit → fire → settle; ballistic via weapon.projectile
2114
- death onDeath (reason-aware drops/command), entity.died, auto kill attribution + drop grant
2115
- game.loot register / has / roll / grantToPlayer (lootTable() = pure factory)
2116
- game.trade canBuy / canSell / buy / sell / tradableAt
2117
- game.quest register, accept…turnIn, bind(entity.died | inventory.added), declarative rewards
2118
- game.social friends (persisted, requests listable), party (ephemeral, invites listable), presence, emotes (nearby broadcast), worldInvites (accept → join target)
2119
- game.chat send / whisper / history / register — global/party/proximity channels, rate-limited, mute via blocked set
2120
- game.roster capture / release / list / setEquipped — persisted owned-creature roster
2121
- game.store/cards/turn store: keyed reactive slot; cards.pile(id): lazy CardPile; turn.loop(id): lazy TurnLoop
2122
- game.events/feed/leaderboard on / bind+push+recent / track+increment+getTop
2123
- devtools F2 overlay (Perf/Tune/Logs/Net/Keys); zero-annotation — top-level export const number/boolean/color + exported flat tables auto-discover into Tune; tunable() is the optional low-level escape hatch; snapshotDevtools()/window.__JG_DEVTOOLS.snapshot() → DevtoolsSnapshot (+ discovered[]); probes.register(name, read) adds a Perf gauge
2124
- applyLoadout all-or-nothing kit seeding per userId
2125
- player.movement pose (hitboxes) + aim (zoom modifier)
2126
- player.motion impulse / setVerticalVelocity / setY — vertical-motion seam into the shell's frame driver
2127
- player.possession own/disown/owns/listOwned + active + possess — control-swap, rebinds shell camera
2128
- player.cosmetics register + apply/equip + get — per-player appearance slots, no gameplay effect
2129
- scene.entity.form register + shapeshift/revert + active/abilities — movement+ability+mesh bundle, game-time duration
2130
- proximityPrompt { radius, display: {kind}, invoke } — one float-UI primitive
2131
- skillCheck/qte evaluateSkillCheck (moving zone + window) / evaluateQteSequence (timed steps)
2132
- captureCheck captureChance / rollCapture — hp% + catchPower → probability
2133
- dialogue check DialogueChoice.check (roll vs DC + advantage/disadvantage) → onSuccess/onFailure
2134
- world features biomes / voxel / plots / tilemap / flat descriptors
2135
- physics/physicsWorld optional headless rigid-body sim (PhysicsWorld) — not the defineGame physics field (gravity/jumpVelocity, honored by the kinematics controller); bodies are box (halfExtents) or sphere (radius)
2136
- physics/ballisticSweep createBallisticSweep(world) → arc-vs-body hit test; wire into ProjectileSystemDeps.sweepBallistic
2137
- anim/easing lerp/clamp01/smoothstep/tween/timedProgress + easeIn/Out/InOut Quad/Cubic + easeOutBack/Elastic — pure 0..1 tweening math
2138
- data/dataSource createDataSource/createJsonDataSource — idle/loading/ready/error async state + polling; data/fetchJson + data/devProxy for CORS-safe dev fetches
2139
- audio/audioFalloff computeFalloffGain / resolveEmitterGain — pure distance→gain curve; shell plays it
2140
- time/beatClock createBeatClock (BPM ticks) + createBeatInputBuffer (buffered action → next beat)
2141
- ws/voiceChannel createVoiceChannelRouter — positional falloff + simultaneous non-positional channels
2142
- multiplayer/identity AuthSession + sessionPlayer + resolveGuestSession — Clerk/better-auth via react structural adapters
2143
- multiplayer/chatContract ChatTransport (hooks) / ChatSync (callbacks) — ws + convex bindings, local for dev
2144
- multiplayer/voiceContract VoiceTransport (join/leave/publish/subscribers) + createPushToTalk — media plane host-supplied
2145
- GameBackend { transport, feeds?, presence? } — Convex is one adapter (createConvexBackend)
2146
- adapter kinds offline / ws / convex / socketIo / p2p / lan (+ fly({app}) ws sugar) — runtime/adapter
2147
- ws/pipe TransportPipe/TransportPipeFactory — any bidirectional string channel (webSocketPipe default)
2148
- ws/host, ws/hostRouter browser-safe createGameHost + createHostRouter/loopbackPipe (node re-exports both)
2149
- ws/peer createPeerHost/createPeerGuest — WebRTC P2P, host tab authoritative, copy-paste signal codes
2150
- ws/socketIoPipe, node/socketIoServer socketIoPipe/createSocketIoBackend + attachGameSocketIoServer
2151
- @jgengine/react GameProvider + hooks + headless primitives (incl. identity/chat/voice/social kits); layout only in GameUI.tsx
2152
- ```
2153
-
2154
- Engine ships verbs and primitives. Your game ships nouns.
2155
-
2156
- # jgengine-api — UI — @jgengine/react
2157
-
2158
- Reference module for the [`jgengine-api`](../SKILL.md) skill. Load this when you need the React UI layer. The **UI quality bar** section at the bottom is how the HUD must look and behave — required, not optional polish.
2159
-
2160
- ```tsx
2161
- import { GameProvider, useSceneEntities, HealthBar } from "@jgengine/react";
2162
-
2163
- <GameProvider context={ctx}>…</GameProvider>
2164
- ```
2165
-
2166
- Import provider, hooks, and headless components from the package root `@jgengine/react` (a barrel re-export). The per-file subpaths (`@jgengine/react/provider`, `/hooks`, `/components`) resolve the same symbols if you prefer them.
2167
-
2168
- All hooks bind through the ctx change signal (`ctx.subscribe`/`ctx.version`):
2169
-
2170
- | Hook | Returns |
2171
- |------|---------|
2172
- | `useGame()` / `usePlayer()` | `{ commands, events }` / `{ userId, isNew }` |
2173
- | `useSceneEntities()` / `useSceneObjects()` | live snapshots for rendering |
2174
- | `useWorldItems()` / `useNearestWorldItem(radius)` | ground-loot snapshots / nearest pickup for a HUD prompt |
2175
- | `useEntityStat(instanceId, statId)` | `StatValue \| null` |
2176
- | `useTarget(fromId)` | locked instanceId \| null |
2177
- | `useInventory(id)` / `useCurrency(id)` | slots / balance |
2178
- | `useFeed({ action, limit? })` | recent entries — kills, loot, any action |
2179
- | `useQuestJournal()` | active quests + objective progress |
2180
- | `useFriends()` / `useParty()` / `usePresence(userId)` / `useWorldInvites()` | social panels |
2181
- | `useFriendRequests()` / `usePartyInvites()` | pending inbound requests/invites for the local player |
2182
- | `useWorldBrowser({ fetchSessions, filter?, limit?, refreshMs? })` | polls a host-supplied fetcher (e.g. `createWsBackend().browse`) through matchmaking's `browseSessions` |
2183
- | `useSession()` / `useAuthedPlayer({ guestSeed? })` | auth session from `<GameIdentityProvider>` / the `{ userId, isNew }` player seam for `createGameContext` |
2184
- | `useChat(channelId, { limit? })` | local-player-filtered recent messages from `ctx.game.chat` |
2185
- | `useVoice({ transport?, channelId?, mode?, resolveRoutes? })` | mic capture + PTT + voice-channel roster over the `VoiceTransport` seam |
2186
- | `useRoster(userId?)` | owned/captured roster entries for a user (defaults to the local player) |
2187
- | `useLeaderboard(stat, { scope, limit? })` | `{ userId, value }[]` |
2188
- | `useActivePrompt(prompts)` | nearest proximity prompt |
2189
- | `useGameClock()` | clock snapshot (`now`, `paused`, `speed`, `calendar`) + `controls` (pause/play/setSpeed) |
2190
- | `useLocalPlayerDead()` / `localPlayerEntity(entities, userId)` | death-screen gating; local player from a snapshot |
2191
- | `useMarkers(markerSet)` / `useFog(fogField)` | live map-marker list / fog-cell snapshot (bind a core `MarkerSet`/`FogField`) |
2192
- | `useGameStore()` | raw store handle — escape hatch under the typed hooks |
2193
- | `useEngineState(store)` / `useEngineStore(store, selector)` / `useEngineEvent(store, event, handler)` | bind/select/subscribe against any `ReadableEngineStore<TState>` / `EventfulEngineStore<TEventMap>` — the escape hatch below `useGameStore()` for state that isn't wired into a typed hook yet |
2194
- | `useHeldKeys()` | `(code: string) => boolean` — raw window keydown/keyup/blur-backed held-key predicate, no `ctx` needed; the primitive `useAxisChannel` and the shell's own movement sampling are built on |
2195
- | `useAxisChannel(config: AxisChannelConfig)` | `{ channel: AxisChannel, isDown }` — wires `useHeldKeys` into a fresh `AxisChannel` (`@jgengine/core/input/axisInput`) for a per-frame `channel.sample(dt, isDown)`; a driving/twin-stick HUD or custom control scheme reads analog throttle/steer without touching `window` directly |
2196
-
2197
- Import hooks from `@jgengine/react/hooks`, components from `@jgengine/react/components`, `GameProvider` from `@jgengine/react/provider` (the package uses deep paths like core). `useEngineState`, `ReadableEngineStore`, `useEngineStore`, `useEngineEvent` also ship from the main `@jgengine/react` entry point (defined in `@jgengine/react/engineStore`, re-exported at the package root) — no deep import required.
2198
-
2199
- Headless components (className passthrough, no baked-in styling): `SlotGrid`, `HealthBar` (+ `fillClassName`), `CurrencyPill`, `ProximityPrompt`, `Screen`, `KeybindRow`, `DialogueBox` (+ `lineClassName`/`speakerClassName`/`choicesClassName`/`choiceClassName`/`checkClassName`, `rollCheck`-gated choices), `SkillCheckBar` (+ `trackClassName`/`zoneClassName`/`markerClassName`), `QteTrack` (+ `stepClassName`/`activeClassName`/`doneClassName`), `CaptureOdds` (+ `fillClassName`), `ToastStack`, `DeathScreen`, `LevelUpFlash`. Map components (bind a core `MarkerSet`/`FogField`, `kindStyles` palette overridable): `Minimap` (framed circular player-centered map — fog + markers + facing arrow, optional baked terrain `background`+`mapBounds`), `Compass` (facing strip with cardinals + marker pips), `WorldMap` (full-bounds top-down overlay). Not yet implemented: `useServer`, `useDialogue`.
2200
- **Identity (`@jgengine/react/identity`)** — `<GameIdentityProvider source={…}>` + `useSession()`. Sources: `clerkIdentity({ isLoaded, isSignedIn, user })` maps Clerk's `useUser()` shape, `betterAuthIdentity({ data, isPending })` maps better-auth's `useSession()` shape (both pure structural mappers — no SDK imports, one line at the call site), `guestIdentity(seed?)` for local/dev. Gate UI with `<RequireSession fallback loading>`; `<UserBadge>` / `<SignOutButton>` are headless like everything else. `useAuthedPlayer({ guestSeed? })` returns the `{ userId, isNew }` to hand `createGameContext` — feed the player seam from the session instead of hand-picking a userId.
2201
- **Chat (`@jgengine/react/chat`)** — headless `<ChatPanel>` (tabs + log + input composition with internal active-channel state), or compose `<ChannelTabs active onSelect>`, `<ChatLog channelId>` (auto-scrolls, `renderMessage` override), `<ChatInput channelId onSent onRejected>` yourself. All drive `ctx.game.chat` through `useChat`. `chatTransportFromSync(sync)` lifts a callback-style `ChatSync` (e.g. `createWsBackend(...).chatSyncFor(serverId)`) into the hook-shaped `ChatTransport` for remote chat.
2202
- **Voice (`@jgengine/react/voice`)** — `useVoice()` once per channel: `getUserMedia` mic capture (`requestMic()`, tracks gated by transmission), push-to-talk via `createPushToTalk` (hold/toggle/openMic + mute), roster from `VoiceTransport.subscribers`, and per-speaker `gainFor(userId)` when you pass `resolveRoutes: () => router.resolveRoutes(myUserId)` from `@jgengine/ws/voiceChannel`. Hand the returned state to the headless `<PushToTalkButton voice>`, `<MicToggle voice>`, `<SpeakingIndicator voice userId>`, `<VoiceRoster voice>`.
2203
- **Social (`@jgengine/react/social`)** — the headless social kit over `ctx.game.social`: friends (`<FriendsList>`, `<FriendRow>`, `<PresenceDot>`, `<AddFriendButton toUserId>`, `<FriendRequestsList>` with accept/decline), party (`<PartyFrame>`, `<PartyMemberRow>`, `<PartyInviteToast>`, `<LeavePartyButton>`), worlds (`<WorldBrowser listings onJoin>`, `<JoinByCode onJoin>` — normalizes codes, `<QuickMatchButton listings filter?>`, `<InviteToWorldButton toUserId target>`, `<WorldInviteToast onAccepted>` — hands you the `{ serverId, joinCode? }` join target), and `<EmoteWheel emotes>` over `emotes.play`. All className-passthrough with `data-*` hooks and `renderX` overrides; the `social-hub` demo in `apps/dev` (`?game=social-hub`) composes the whole kit.
2204
- **Drag/rotate/drop/snap gesture layer** (`@jgengine/react/dragLayer`) — a 2-D UI-space gesture layer over the card/shaped-grid primitives, distinct from 3-D world drag. `useDragLayer<T>({ onDrop })` owns pointer-follow drag state (begin/rotate/setTarget/end); pair it with the headless, className-passthrough `DraggableCard` (right-click rotates), `DropZone` (reports the snapped `cellFromPoint` cell + active state), and `DragGhost` (a pointer-anchored preview). Drop resolution and overlap validation stay the game's job via `canPlace`/`placeShaped` from `inventory/shapedGrid` — Balatro hand→play drags, Backpack Hero grid placement, Slay-the-Spire card-onto-enemy targeting.
2205
-
2206
- **Layout rule:** all **screen** positioning (`absolute`, `inset-*`, grid zones, flex regions) lives on wrappers inside `ui/GameUI.tsx` only. `ui/components/` files are content + hooks only — internal `relative`/`absolute` for bar overlays or slot badges inside a component is fine; never anchor a component to the viewport from a child file. Pass `className` to primitives for **visual** styling (colors, borders, size), not screen placement.
2207
-
2208
- **Tailwind sources:** add `@source` entries in your CSS for your game source dirs plus `node_modules/@jgengine/shell` and `node_modules/@jgengine/react`. Without them, classes used in dynamically imported game code are **not generated** — layout wrappers in `GameUI.tsx` silently fail and every HUD cluster stacks in one corner.
2209
-
2210
- ## Visual HUD via the shadcn registry
2211
-
2212
- Styled HUD components are **copy-in code**, not npm exports — install them from the JGengine registry with the shadcn CLI:
2213
-
2214
- ```sh
2215
- npx shadcn@latest add https://jgengine.com/r/entity-vital-bar.json
2216
- ```
2217
-
2218
- The component lands in your game's `components/ui/`, themes through `--jg-*` CSS variables, and reads engine data through `@jgengine/react/hooks`. Health bar hookup, end to end:
2219
-
2220
- ```tsx
2221
- import { usePlayer } from "@jgengine/react/hooks";
2222
- import { EntityVitalBar } from "@/components/ui/entity-vital-bar";
2223
-
2224
- const { userId } = usePlayer();
2225
- return <EntityVitalBar instanceId={userId} statId="health" />;
2226
- ```
2227
-
2228
- The full HUD catalog ships as registry items — vitals, slots, feedback, meters, panels, screens, reticles, plus the `game-icon` glyph catalog (`iconForItemId`/`iconForAction`), with engine-bound `Entity`/`Ability`/`Journal`/`Feed`/`Wallet` variants wired to the hooks. Where a registry item exists, prefer it — and never hand-roll a gray-box version of a component the registry already ships.
2229
-
2230
- ### UI quality bar (required — not optional polish)
2231
-
2232
- Headless primitives mean **you** ship the visual design. Functional wiring alone is not shippable UI. Judge against staged screenshots, never your mental model of the code.
2233
-
2234
- **See what you ship.** `@jgengine/shell`'s `GameUiPreview` renders your `GameUI` over a staged `GameContext` (ticks run, hostile targeted, first ability fired) with no gameplay or backend — mount it on a dev route, screenshot it, and judge the image before calling any HUD work done; pass a custom `scenario` to stage richer states (open modals, low health, active quest). Type-green says nothing about whether the HUD renders.
2235
-
2236
- | Requirement | Minimum |
2237
- |-------------|---------|
2238
- | **Contrast** | HUD text and borders readable on the game's scene background — never bare `text-stone-400` on near-black without a panel |
2239
- | **Scale** | Primary HUD (unit frames, hotbar slots, menu buttons) ≥ 48px touch targets; body text ≥ `text-sm` (12px); key labels never below 11px |
2240
- | **Distinct construction** | Unit frame, hotbar, currency, quests, and toasts must not share one card style — same `rounded border bg-stone-900/80 p-3` on everything reads identical and cheap |
2241
- | **Real icons** | Every item/ability/hotbar slot shows a real, distinct silhouette or sprite (registry `game-icon`, asset-pack sprite) — never a gray box, first letter, emoji, or one generic shape reused everywhere |
2242
- | **Hotbar / slots** | Icon per ability; keybind badge on the slot corner; hover/active state; empty slots visually distinct |
2243
- | **Unit frames** | Name + level + labeled bars with numeric values; health/mana/resource colors genre-appropriate |
2244
- | **Layout** | No overlapping anchors; reserve space for frames that appear conditionally (target, quest log) |
2245
- | **Panels** | Modal/slide panels: title, close control, section headers, consistent chrome with the HUD |
2246
- | **Feedback** | Errors, cooldowns, and empty actions surface to the player (toast, dim, shake) — not `console.warn` only. Error text is ephemeral floating combat text, never a bordered toast card |
2247
-
2248
- **Genre fit:** MMO/RPG → ornate dark panels, gold accents, portrait + bars, action bar with icons. Shooter → crosshair + ammo + ability cooldowns. Tycoon → resource pills + build menus. Match the game's fantasy; do not ship debug-gray placeholders.
2249
-
2250
- **Panel vs frameless.** Modal/panel chrome (backdrop + bordered window) is for on-demand windows only: backpack/bags, combat log/chat feed, social window. Everything persistent stays frameless — typography, bars, icons, shadows, no enclosing card: player/target/party frames, action bar, quest tracker, currency, voice cluster, floating combat text, world VFX. Backpack is the only bag UI (no hotbar inside it); character sheet holds equipment + stats; the abilities page lists catalog abilities with costs/cooldowns/keybinds, not a hotbar duplicate.
2251
-
2252
- **Keybinds are badges on their control.** Every toggle and hotbar slot shows its binding on itself (slot corner, toggle button) — never a persistent "WASD to move / E to interact" legend pinned to the screen; if a control needs explaining, badge it or use a proximity prompt that fades. Labels derive from the game's `keybinds.ts` via `actionLabel(keybinds, action)` — hardcoded key strings drift. Register actions in `defineGame.input`, wire through `game.commands` (never UI click handlers only), and read the binding table once before shipping: **one key, one action**. Panel toggles follow the `ui.openBackpack`-style command pattern with state in `ctx.game.store` (`useGameStore`), not a hand-rolled module store.
2253
-
2254
- **Action bar slot states — all four, visually:** ready (full color), cooldown (dim + sweep + numeric timer), no-resource (red tint/desaturate, cost checked before press), just-cast (brief bright ring flash ~200ms). Cooldown data lives in game code; UI reads it.
2255
-
2256
- **Combat feedback:** bolts/bullets are `fireProjectile` + delayed `settleProjectile` so they visibly travel — never `effect({ to })`; instant heals flash the unit-frame bar; out-of-range/oom is floating text that fades, no bordered toast.
2257
-
2258
- **Mobile / touch:** HUD fits a 390px portrait viewport with no horizontal overflow (`min()`/viewport units once compact); `useDisplayProfile().compact` (`@jgengine/react/display`) collapses side panels into a slim top bar; keep the bottom ~180px clear for the engine's touch dock on `coarsePointer`; never render key badges/legends on touch; non-interactive wrappers stay `pointer-events-none` (only real controls opt in with `pointer-events-auto`); ≥48px touch targets always.
2259
-
2260
- **Social HUD:** build from the headless kits (`@jgengine/react/social`, `/chat`, `/voice`, `/identity`), never hand-rolled lists. Chat is a corner-anchored panel (channel tabs, log, input; sender names tinted apart from bodies); invite toasts are ephemeral with accept/decline that expire with the invite; presence is a dot (`data-online`), not a word; push-to-talk badges its keybind and visibly transmits (`data-transmitting`), speaking players glow on their party row; emote wheel appears on hold-key and fades. Every social button drives an engine verb (`social.friends.request`, `party.accept`, `worldInvites.accept` → join) — one that only mutates local UI state is half a system.
2261
-
2262
- **Camera feel is part of the HUD pass.** The shell's orbit camera (left-drag orbit, scroll zoom, tap = primary ability, camera-relative WASD) tunes per game via the `camera` field of `defineGame({...})` (`minDistance`, `maxDistance`, `targetHeight`, `rotateSpeed`, `zoomSpeed`, `dampingFactor`, smoothing) — defaults untouched means the feel was never checked; never hardcode camera position in `onTick` while a rig is active.
2263
-
2264
- **Shared chrome:** extract repeated panel/slot styles into `ui/<theme>.ts` or `ui/components/<Frame>.tsx` — do not copy-paste three classes per file.
2265
-
2266
- **Self-check before calling UI done** (against actual staged screenshots):
2267
-
2268
- - [ ] Screenshot at 1080p: can you read every label without squinting?
2269
- - [ ] Could you mistake the unit frame and hotbar for the same component? If yes, redo.
2270
- - [ ] Every toggle shows its key on its own control — and no persistent controls legend anywhere?
2271
- - [ ] Every item/ability slot shows a real, distinct icon — no gray boxes, letters, or emoji?
2272
- - [ ] Do bolts visibly travel before damage lands?
2273
- - [ ] Only on-demand windows (backpack, log, social) have panel borders?
2274
- - [ ] Every declared system has its UI end state — quest → tracker, cooldown → sweep + timer, error → floating text? A wired hook with no visual is half a system: finish it or cut it (see `jgengine-newgame`).
2275
- - [ ] Does the staged shot show the HUD *working* (target locked, cooldown mid-sweep, tracker populated), not resting-empty?
2276
- - [ ] Would a player think this is intentional art direction? If it looks like a debug build, it ships nothing.
2277
-
2278
- # jgengine-api — World features
2279
-
2280
- Reference module for the [`jgengine-api`](../SKILL.md) skill. Load this when you need the renderer-free world surface.
2470
+ Reference module for the [`jgengine-world` API](SKILL.md) skill. Load this when you need the renderer-free world surface.
2281
2471
 
2282
2472
  ## World features
2283
2473
 
2284
- Descriptors from `@jgengine/core/world/features` config data the runner/world layer interprets:
2474
+ Descriptors from `@jgengine/core/world/features` — config data the runner/world layer interprets:
2285
2475
 
2286
2476
  | Feature | Use |
2287
2477
  |---------|-----|
@@ -2290,93 +2480,164 @@ Descriptors from `@jgengine/core/world/features` — config data the runner/worl
2290
2480
  | `plots(config)` | Shared city + instanced interiors |
2291
2481
  | `tilemap({ map })` | 2D/2.5D levels |
2292
2482
  | `flat()` | Plain arena |
2293
- | `environment({ terrain, sky, weather, vegetation, water, structures, pads })` | Composable outdoor scene terrain + sky/time-of-day + rain/snow + grass + ocean + buildings + ground pads. Each field takes the matching descriptor: `terrain()`, `sky()`, `rain()`/`snow()`, `grass()`, `ocean()`, `building()`, `pad()`. `building()` and `ocean()` take `position: [x, z]` to site a cluster/water body away from the origin (several settlements, an offset lake); building clusters ground-snap to the terrain field per building; each `pad()` (a flat platform/paved patch `{ center, size: [w,d] | { radius }, height?, color?, rotationY? }`) implicitly flattens the terrain beneath it via a `TerrainFlattenMask`, so a building pad or spawn circle never fights the noise field underneath |
2483
+ | `environment({ terrain, sky, weather, vegetation, water, structures, pads })` | Composable outdoor scene — terrain + sky/time-of-day + rain/snow + grass + ocean + buildings + ground pads. Each field takes the matching descriptor: `terrain()`, `sky()`, `rain()`/`snow()`, `grass()`, `ocean()`, `building()`, `pad()`. `building()` and `ocean()` take `position: [x, z]` to site a cluster/water body away from the origin (several settlements, an offset lake); building clusters ground-snap to the terrain field per building; each `pad()` (a flat platform/paved patch — `{ center, size: [w,d] | { radius }, height?, color?, rotationY? }`) implicitly flattens the terrain beneath it via a `TerrainFlattenMask`, so a building pad or spawn circle never fights the noise field underneath |
2294
2484
 
2295
- `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 cellinstance 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.
2485
+ `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.
2296
2486
 
2297
- `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. `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`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, 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). **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-newgame`'s first-shot art recipe).
2487
+ `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. `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`). `sky({ preset?, timeOfDay?, horizonColor?, zenithColor?, sunIntensity?, ambientIntensity?, 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). **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).
2298
2488
 
2299
- `parentSpace` positions are local to that space convert at seams only.
2489
+ `parentSpace` positions are local to that space — convert at seams only.
2300
2490
 
2301
2491
  ### Query primitives (renderer-free, for gameplay)
2302
2492
 
2303
- Pure `@jgengine/core` functions so gameplay reads the same world the shell renders no three.js needed:
2493
+ Pure `@jgengine/core` functions so gameplay reads the same world the shell renders — no three.js needed:
2304
2494
 
2305
2495
  | Primitive | Answers |
2306
2496
  |-----------|---------|
2307
- | `resolveTerrainField(terrain(...))` / `noiseField(cfg)` `TerrainField` | `sampleHeight(x,z)`, `sampleNormal(x,z)`, `waterLevel` ground-snap, collision, camera. `resolveGroundStep` slope-limits movement |
2308
- | `snapToGround(field, position, offset?)` `[x,y,z]` | Replaces a position's `y` with the field's sampled ground height (+ `offset`) at its `x`/`z` the pure version of a spawn/placement ground-snap |
2309
- | `snapEntityToGround(entities, id, field, offset?)` `boolean` | Ground-snaps an already-spawned entity in place via `entities.setPose` false when `id` is unknown; the imperative sibling of `snapToGround` for "drop this entity onto the terrain right now" (mount dismount, teleport, respawn) |
2310
- | `windField(cfg)` `WindField` | `at(t)`, `atPoint(x,z,t)`, `strengthAt` one wind source for weather sway, grass, sailing, fire spread |
2311
- | `waterSurface(cfg)` / `waterSurfaceFromDescriptor(ocean(...))` `WaterSurface` | `height(x,z,t)`, `normal`, `displace` buoyancy, floating, shoreline (CPU Gerstner matching the ocean shader) |
2312
- | `scatter(cfg)` `ScatterPoint[]` | Seeded, overlap-aware point distribution vegetation, props, lots, spawn points (`minDistance`, `avoid` rects) |
2313
- | `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 |
2314
- | `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) |
2315
- | `buildingIndex(district)` `BuildingIndex` | `at`/`within`/`nearest`/`isInside`/`blockers` over a generated district placement avoidance, pathfinding |
2497
+ | `resolveTerrainField(terrain(...))` / `noiseField(cfg)` → `TerrainField` | `sampleHeight(x,z)`, `sampleNormal(x,z)`, `waterLevel` — ground-snap, collision, camera. `resolveGroundStep` slope-limits movement |
2498
+ | `snapToGround(field, position, offset?)` → `[x,y,z]` | Replaces a position's `y` with the field's sampled ground height (+ `offset`) at its `x`/`z` — the pure version of a spawn/placement ground-snap |
2499
+ | `snapEntityToGround(entities, id, field, offset?)` → `boolean` | Ground-snaps an already-spawned entity in place via `entities.setPose` — false when `id` is unknown; the imperative sibling of `snapToGround` for "drop this entity onto the terrain right now" (mount dismount, teleport, respawn) |
2500
+ | `windField(cfg)` → `WindField` | `at(t)`, `atPoint(x,z,t)`, `strengthAt` — one wind source for weather sway, grass, sailing, fire spread |
2501
+ | `waterSurface(cfg)` / `waterSurfaceFromDescriptor(ocean(...))` → `WaterSurface` | `height(x,z,t)`, `normal`, `displace` — buoyancy, floating, shoreline (CPU Gerstner matching the ocean shader) |
2502
+ | `scatter(cfg)` → `ScatterPoint[]` | Seeded, overlap-aware point distribution — vegetation, props, lots, spawn points (`minDistance`, `avoid` rects) |
2503
+ | `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 |
2504
+ | `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) |
2505
+ | `buildingIndex(district)` → `BuildingIndex` | `at`/`within`/`nearest`/`isInside`/`blockers` over a generated district — placement avoidance, pathfinding |
2316
2506
 
2317
- **Voxel field (`world/voxelField`).** `createVoxelField<T extends string>({ chunkSize? })` (default 16) is a logical block lattice for voxel games and instanced renderers distinct from the `voxel()` `WorldFeature` descriptor above (that's the runner-level world kind; this is the block data structure a voxel game's gameplay reads and writes). `set`/`remove`/`get`/`has`/`fill`/`clear`/`count`/`cells`/`bounds` are the block CRUD (`set` returns `false` only when the identical type is already there a no-op write). `neighbors(x, y, z)` returns the 6-adjacent occupied cells; `exposedFaces(x, y, z)` returns the `VoxelFace`s (`"px"|"nx"|"py"|"ny"|"pz"|"nz"`) not touching another voxel feed that straight into greedy-meshing/face-culling. `raycast(origin, direction, maxDistance)` runs a 3D DDA and returns `{ x, y, z, type, face, adjacent, distance }`, where `adjacent` is the empty cell just in front of the hit the placement target for block-place tools. Renderers dirty-track via `chunkOf(x, y, z)` + `chunkVersion(chunk)` and `subscribe(listener)`, so an instanced mesh only rebuilds the chunks that changed. For a non-`environment()` voxel world, assert on `field.summary()` (`{ blocks, types, bounds }`) the same way an `environment()` world asserts on `summarizeEnvironment` (see `jgengine-verify`).
2507
+ **Voxel field (`world/voxelField`).** `createVoxelField<T extends string>({ chunkSize? })` (default 16) is a logical block lattice for voxel games and instanced renderers — distinct from the `voxel()` `WorldFeature` descriptor above (that's the runner-level world kind; this is the block data structure a voxel game's gameplay reads and writes). `set`/`remove`/`get`/`has`/`fill`/`clear`/`count`/`cells`/`bounds` are the block CRUD (`set` returns `false` only when the identical type is already there — a no-op write). `neighbors(x, y, z)` returns the 6-adjacent occupied cells; `exposedFaces(x, y, z)` returns the `VoxelFace`s (`"px"|"nx"|"py"|"ny"|"pz"|"nz"`) not touching another voxel — feed that straight into greedy-meshing/face-culling. `raycast(origin, direction, maxDistance)` runs a 3D DDA and returns `{ x, y, z, type, face, adjacent, distance }`, where `adjacent` is the empty cell just in front of the hit — the placement target for block-place tools. Renderers dirty-track via `chunkOf(x, y, z)` + `chunkVersion(chunk)` and `subscribe(listener)`, so an instanced mesh only rebuilds the chunks that changed. For a non-`environment()` voxel world, assert on `field.summary()` (`{ blocks, types, bounds }`) the same way an `environment()` world asserts on `summarizeEnvironment` (see `jgengine-verify`).
2318
2508
 
2319
- **Destructible terrain (`world/carve`).** Two runtime-editable primitives for dig/carve worlds. `VoxelVolume` is a dense grid of material ids (0 = empty) `carve({ center, radius, toolStrength })` clears a sphere of solid cells the tool is strong enough to break and returns the count removed (feed a loot roll), `deposit({ center, radius, material })` fills one (Deep Rock tunnels, Astroneer terrain add); `solidAtWorld` reads it back for collision. `CarvableField` (via `carvableTerrain(base)`) wraps any `TerrainField` and writes craters/mounds into its height `carve({ x, z, radius, depth })`/`deposit({ x, z, radius, height })` so ground-snap, collision, and the shell mesh all read the deformed surface (Helldivers 2 explosion craters). Cell strengths come from a `VoxelMaterial` table (DATA). Renders through `@jgengine/shell/terrain/CarvedTerrain`.
2509
+ **Destructible terrain (`world/carve`).** Two runtime-editable primitives for dig/carve worlds. `VoxelVolume` is a dense grid of material ids (0 = empty) — `carve({ center, radius, toolStrength })` clears a sphere of solid cells the tool is strong enough to break and returns the count removed (feed a loot roll), `deposit({ center, radius, material })` fills one (Deep Rock tunnels, Astroneer terrain add); `solidAtWorld` reads it back for collision. `CarvableField` (via `carvableTerrain(base)`) wraps any `TerrainField` and writes craters/mounds into its height — `carve({ x, z, radius, depth })`/`deposit({ x, z, radius, height })` — so ground-snap, collision, and the shell mesh all read the deformed surface (Helldivers 2 explosion craters). Cell strengths come from a `VoxelMaterial` table (DATA). Renders through `@jgengine/shell/terrain/CarvedTerrain`.
2320
2510
 
2321
2511
  Renderers for these descriptors live in `@jgengine/shell` (`shell/terrain`, `shell/water`, `shell/weather`, `shell/structures`).
2322
2512
 
2323
2513
  ### Environment fields, weather hooks & realm composition
2324
- 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`.
2325
- - **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.
2326
- - **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.
2327
- - **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.
2328
- - **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.
2514
+ 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`.
2515
+ - **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.
2516
+ - **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.
2517
+ - **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.
2518
+ - **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.
2329
2519
  ### Survival meters, moodles & multi-region health
2330
- The `survival/` domain decay-over-time meters and per-part health, both feeding one stacking **moodle** status display distinct from numeric bars.
2331
- - **Decay meters** (`survival/decayMeter`): `createDecayMeterSet([{ id, max, min?, start?, rate, thresholds }])` `DecayMeterSet`. Each named meter (hunger, thirst, oxygen, sanity, warmth, stamina) drains/recovers on `tick(dt)` at `rate`, refills from consumables/actions via `refill(id, amount)`, and raises threshold moodles (`below`/`above`). `setRateModifier(id, mult)` lets the environment drive them read an env field, then speed warmth loss when cold or oxygen loss in a toxic biome.
2332
- - **Moodles** (`survival/moodle`): the shared status stack, distinct from raw bars. `stackMoodles(...groups)` folds meter, ailment, and buff `Moodle[]` into one worst-first display (same-id stacks add, worst severity wins). `createMoodleStack()` holds timed buffs (`add({ id, label, duration })` Valheim's concurrent food buffs) and expires them on `tick(dt)`.
2333
- - **Multi-region health** (`survival/regionHealth`): `createMultiRegionHealth({ regions, ailments })` `MultiRegionHealth` gives per-part pools (head/thorax/arms/legs, Tarkov/DayZ style) `damage(regionId, amount)` scales by `vulnerability` and kills when a `vital` part empties; a stacking **ailment queue** (`applyAilment`, `tick(dt)` drains like bleed) carries per-injury treatment (`treat(itemId)` clears wounds via bandage/tourniquet/splint). `ailmentMoodles()` shares the moodle display with the meters (#78 + #90).
2520
+ The `survival/` domain — decay-over-time meters and per-part health, both feeding one stacking **moodle** status display distinct from numeric bars.
2521
+ - **Decay meters** (`survival/decayMeter`): `createDecayMeterSet([{ id, max, min?, start?, rate, thresholds }])` → `DecayMeterSet`. Each named meter (hunger, thirst, oxygen, sanity, warmth, stamina) drains/recovers on `tick(dt)` at `rate`, refills from consumables/actions via `refill(id, amount)`, and raises threshold moodles (`below`/`above`). `setRateModifier(id, mult)` lets the environment drive them — read an env field, then speed warmth loss when cold or oxygen loss in a toxic biome.
2522
+ - **Moodles** (`survival/moodle`): the shared status stack, distinct from raw bars. `stackMoodles(...groups)` folds meter, ailment, and buff `Moodle[]` into one worst-first display (same-id stacks add, worst severity wins). `createMoodleStack()` holds timed buffs (`add({ id, label, duration })` — Valheim's concurrent food buffs) and expires them on `tick(dt)`.
2523
+ - **Multi-region health** (`survival/regionHealth`): `createMultiRegionHealth({ regions, ailments })` → `MultiRegionHealth` gives per-part pools (head/thorax/arms/legs, Tarkov/DayZ style) — `damage(regionId, amount)` scales by `vulnerability` and kills when a `vital` part empties; a stacking **ailment queue** (`applyAilment`, `tick(dt)` drains like bleed) carries per-injury treatment (`treat(itemId)` clears wounds via bandage/tourniquet/splint). `ailmentMoodles()` shares the moodle display with the meters (#78 + #90).
2334
2524
  ### Interactive building & terraform (renderer-free tools)
2335
2525
  Turn data-only placement into the build tooling of Valheim/Enshrouded/The Sims/Fortnite/Dinkum. All pure `@jgengine/core/world`; the shell renders the ghost/tint/brush (`shell/structures/PlacementGhost`, `shell/terrain/EditableGround`, `shell/terrain/TerraformBrushCursor`) driven by `pointer.worldHit()`.
2336
2526
  | Primitive | Answers |
2337
2527
  |-----------|---------|
2338
- | `createPlacementController({ footprint, rules, snapMode, grid })` | Owns the ghost: `hover(hit)` `PlacementPreview` (`valid` tint wraps `validatePlacement`), `rotate()`, `setSnapMode`/`cycleSnapMode` (`"grid"`/`"free"`/`"surface"`), `commit()` `PlacementCommit` (`rotationY` via `quarterTurnsToRotationY`). Feed it `pointer.worldHit()`. |
2339
- | `snapToNearest(registry, placed, movingDef, cursor, { snapDistance })` | Typed connector sockets snaps a piece's socket onto the nearest **compatible** placed socket (`socketsCompatible` = both sides `accept` the other type). `worldSockets`/`socketWorldPosition` expand a piece's sockets to world space. |
2340
- | `solveSupport(pieces, links, { maxDistance })` `SupportResult` | Walks the connector graph to any `grounded` piece: `supported` stays, `unsupported` collapses, `distance` (hops-to-ground) drives the whitered decay tint. `toDebrisBodies(pieces, unsupported)` `AddBodyOptions[]` for the `PhysicsWorld` debris sink. |
2341
- | `createWallDrawTool({ snap, closeTolerance })` | Drag wall points auto-encloses when the path returns to the start (`isEnclosed`), `footprint()` derives the room `EnclosedFootprint`, `roof()` auto-fits a hip/gable/flat `RoofPlan`. `createSurfacePaint()` stores per-tile floor/wall surfaces. |
2342
- | `createPlacedStructureStore()` | Save/load a built layout: `add`/`move`/`rotate`/`remove`/`select`, `snapshot()`↔`load()` round-trip (survives reload), `subscribe` for the renderer. |
2343
- | `createEditableTerrain({ bounds, base, cellSize })` `EditableTerrain` | A `TerrainField` you can **write back to**: `apply(edit: TerraformEdit)` raises/lowers/flattens/paints under a cursor and re-samples `sampleHeight`; `surfaceAt`, `snapshot`/`restore`, `reset`. `createTerraformBrush(field)` is the cursor tool (`raise`/`lower`/`flatten`/`paint`, radius/strength). This write-back grid is the shared terrain-edit pattern. |
2528
+ | `createPlacementController({ footprint, rules, snapMode, grid })` | Owns the ghost: `hover(hit)` → `PlacementPreview` (`valid` tint wraps `validatePlacement`), `rotate()`, `setSnapMode`/`cycleSnapMode` (`"grid"`/`"free"`/`"surface"`), `commit()` → `PlacementCommit` (`rotationY` via `quarterTurnsToRotationY`). Feed it `pointer.worldHit()`. |
2529
+ | `snapToNearest(registry, placed, movingDef, cursor, { snapDistance })` | Typed connector sockets — snaps a piece's socket onto the nearest **compatible** placed socket (`socketsCompatible` = both sides `accept` the other type). `worldSockets`/`socketWorldPosition` expand a piece's sockets to world space. |
2530
+ | `solveSupport(pieces, links, { maxDistance })` → `SupportResult` | Walks the connector graph to any `grounded` piece: `supported` stays, `unsupported` collapses, `distance` (hops-to-ground) drives the white→red decay tint. `toDebrisBodies(pieces, unsupported)` → `AddBodyOptions[]` for the `PhysicsWorld` debris sink. |
2531
+ | `createWallDrawTool({ snap, closeTolerance })` | Drag wall points → auto-encloses when the path returns to the start (`isEnclosed`), `footprint()` derives the room `EnclosedFootprint`, `roof()` auto-fits a hip/gable/flat `RoofPlan`. `createSurfacePaint()` stores per-tile floor/wall surfaces. |
2532
+ | `createPlacedStructureStore()` | Save/load a built layout: `add`/`move`/`rotate`/`remove`/`select`, `snapshot()`↔`load()` round-trip (survives reload), `subscribe` for the renderer. |
2533
+ | `createEditableTerrain({ bounds, base, cellSize })` → `EditableTerrain` | A `TerrainField` you can **write back to**: `apply(edit: TerraformEdit)` raises/lowers/flattens/paints under a cursor and re-samples `sampleHeight`; `surfaceAt`, `snapshot`/`restore`, `reset`. `createTerraformBrush(field)` is the cursor tool (`raise`/`lower`/`flatten`/`paint`, radius/strength). This write-back grid is the shared terrain-edit pattern. |
2344
2534
  | `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). |
2345
2535
 
2346
2536
  ### Physics world (optional, headless)
2347
2537
 
2348
- `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`).
2538
+ `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`).
2349
2539
 
2350
- **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.
2540
+ **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.
2351
2541
 
2352
- **Collision shapes are box/sphere/voxel only.** 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. Arbitrary authored level-mesh collision (a sculpted GLB as a collider) is not supported. The seams for custom collision are `movement.beforeCommit` (steer or replace the walk controller's resolved step) and object raycasts (query the scene yourself and react) not a mesh collider.
2542
+ **Collision shapes are box/sphere/voxel only.** 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. Arbitrary authored level-mesh collision (a sculpted GLB as a collider) is not supported. The seams for custom collision are `movement.beforeCommit` (steer or replace the walk controller's resolved step) and object raycasts (query the scene yourself and react) — not a mesh collider.
2353
2543
 
2354
- **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.
2544
+ **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.
2355
2545
 
2356
- **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.
2546
+ **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.
2357
2547
 
2358
- **Joints & constraints.** `hingeJoint`/`fixedJoint`/`distanceJoint`/`springJoint(opts)` connect two bodies, or a body to a fixed world point (omit `bodyB`). The sim is translational (no angular DOF), so `hinge`/`fixed` pin the shared anchor (the `axis` is retained metadata), `distance` holds a fixed separation, and `spring` drives toward `restLength` with `stiffness`/`damping` (suspension, follow-point carry). `removeJoint(id)`, `setJointAnchor(id, x, y, z)` (move anchor B a world anchor's follow point, or body B's local offset), `setJointAnchorA(id, x, y, z)` (move anchor A's local offset e.g. re-rotating a suspension mount each frame as the chassis turns), `setJointRest`, and `readJointSegments(out)` for `@jgengine/shell/world/InstancedJoints` (debug line render). This is the foundation under vehicles, ragdolls, grapples, and carry.
2548
+ **Joints & constraints.** `hingeJoint`/`fixedJoint`/`distanceJoint`/`springJoint(opts)` connect two bodies, or a body to a fixed world point (omit `bodyB`). The sim is translational (no angular DOF), so `hinge`/`fixed` pin the shared anchor (the `axis` is retained metadata), `distance` holds a fixed separation, and `spring` drives toward `restLength` with `stiffness`/`damping` (suspension, follow-point carry). `removeJoint(id)`, `setJointAnchor(id, x, y, z)` (move anchor B — a world anchor's follow point, or body B's local offset), `setJointAnchorA(id, x, y, z)` (move anchor A's local offset — e.g. re-rotating a suspension mount each frame as the chassis turns), `setJointRest`, and `readJointSegments(out)` for `@jgengine/shell/world/InstancedJoints` (debug line render). This is the foundation under vehicles, ragdolls, grapples, and carry.
2359
2549
 
2360
- **Collision gameplay events.** `world.onCollision(listener, minApproachSpeed?)` delivers every impacting contact `CollisionEvent { a, b, nx, ny, nz, approachSpeed, impulse }` to game code during `step` (the object is reused; read/copy it, never retain). This is the seam crash-damage and destruction read; pass `null` to detach.
2550
+ **Collision → gameplay events.** `world.onCollision(listener, minApproachSpeed?)` delivers every impacting contact — `CollisionEvent { a, b, nx, ny, nz, approachSpeed, impulse }` — to game code during `step` (the object is reused; read/copy it, never retain). This is the seam crash-damage and destruction read; pass `null` to detach.
2361
2551
 
2362
- **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 hundredsthousands of simple movers `rebuild(count, xs, zs)` then `queryCircle` (swarm enemies hitting a player/AoE) or `forEachPair` (mutual separation).
2552
+ **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).
2363
2553
 
2364
- **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.
2365
- **Structural destruction (`physics/structure`).** `StructureGraph` models a building as nodes (pieces) + load-bearing edges with some nodes `anchor`ed (foundations). `damage(id, n)`/`damageEdge(a,b,n)`/`severEdge(a,b)` wear pieces and connections; when one breaks, the graph recomputes reachability to an anchor and returns a single `CollapseEvent { fell }` — every piece the loss disconnected. `toDebris(world, event)` sinks the fallen pieces into a `PhysicsWorld` as rigid bodies (The Finals, Rainbow Six). It is coarse by design: **replicate the collapse event (the `fell` id list), not each fragment's physics** — game clients re-derive the debris locally. Piece integrity and edge strength default from a `StructureMaterial` table (DATA).
2554
+ **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.
2555
+
2556
+ **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.
2557
+ **Structural destruction (`physics/structure`).** `StructureGraph` models a building as nodes (pieces) + load-bearing edges with some nodes `anchor`ed (foundations). `damage(id, n)`/`damageEdge(a,b,n)`/`severEdge(a,b)` wear pieces and connections; when one breaks, the graph recomputes reachability to an anchor and returns a single `CollapseEvent { fell }` — every piece the loss disconnected. `toDebris(world, event)` sinks the fallen pieces into a `PhysicsWorld` as rigid bodies (The Finals, Rainbow Six). It is coarse by design: **replicate the collapse event (the `fell` id list), not each fragment's physics** — game clients re-derive the debris locally. Piece integrity and edge strength default from a `StructureMaterial` table (DATA).
2366
2558
  ### Vehicles, mounts, crash damage & racing
2367
- 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)`.
2368
- - **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.
2369
- - **`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.
2370
- - **`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.
2371
- - **`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.
2559
+ 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)`.
2560
+ - **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.
2561
+ - **`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.
2562
+ - **`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.
2563
+ - **`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.
2372
2564
  - **`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.
2373
2565
  - **`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.
2374
- - **`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.
2566
+ - **`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.
2375
2567
 
2376
2568
  ### Spawn placement
2377
2569
 
2378
- `spawn(catalogId, { id?, position | anchor, offset?, parentSpace?, group? })` anchor `{ kind: "entity" | "zone", id }` with offset `{ radius, pattern }` or `{ xyz }`. Catalog supplies movement/model; no behaviors on spawn.
2570
+ `spawn(catalogId, { id?, position | anchor, offset?, parentSpace?, group? })` — anchor `{ kind: "entity" | "zone", id }` with offset `{ radius, pattern }` or `{ xyz }`. Catalog supplies movement/model; no behaviors on spawn.
2571
+
2572
+ **Named spawn/respawn points** (`game/spawnPoints`) — `createSpawnPoints()`: `record(id, { x, y, z, rotationY? })` names a point (level bounds, team spawns, checkpoints), `get(id)`/`list()` read them back, `respawn(entities, entityId, spawnId)` teleports an existing entity to a recorded point via `setPose` in one call — the id-keyed alternative to threading raw coordinates through respawn logic by hand.
2573
+
2574
+ **Level sequence** (`game/levelSequence`) — `createLevelSequence({ levels: [{ id, config }], retriesPerLevel? })` is a pure, deterministic campaign machine: `start()` enters level 0, `clear()` marks the current level cleared, `advance()` moves to the next (or `"complete"` after the last), `fail()` consumes an attempt and returns `"retry"` while `retriesPerLevel` remain else `"failed"`, `retry()` restarts after a retry-eligible failure. `current()` → `{ id, index, config, attempt } | null`; `progress()` → `{ index, total, cleared }`; `reset()` rewinds to idle. Mirrors the reducer style of `game/race` and `ai/spawnDirector` — a level-select/roguelike-run campaign shell without hand-rolling the state machine per game.
2575
+
2576
+ # jgengine-api — Cartridge
2577
+
2578
+ Reference module for the [`jgengine`](./SKILL.md) skill. Load this when building a game as a **cartridge** — one declarative config instead of hand-wired loop/state/UI files. A cartridge game is ~75% smaller than the classic shape and nearly all of it is data; reach for `defineGame` directly only when the game's simulation doesn't fit the cartridge archetypes even through the escape hatches.
2579
+
2580
+ ## Why
2581
+
2582
+ Across the classic games, 78–91% of each game's LOC was generic wiring restated per game: a run-state store with subscribe/notify, chase-and-contact enemy loops, auto-fire weapon routines, magnet pickups, draft plumbing, phase-gated HUD trees, and tests for all of it. The cartridge layer owns every one of those; the game ships only its **data** (catalogs, tuning, layout) and any genuinely bespoke rules through typed escape hatches.
2583
+
2584
+ ## Shape
2585
+
2586
+ `game.config.ts` is the whole game — `cartridge({...})` from `@jgengine/shell/cartridge` compiles the config into a `PlayableGame` (validating it first — a bad reference throws at import with a list of problems). `loop.ts` and `world.ts` disappear; the spec carries the world feature and the loop is engine-owned. `main.tsx` is 5 lines via `mountGame` (`@/components/ui/mount`, registry). Sprites/SVG art stay in `src/game/assets.ts`; the game keeps one test file under `src/game/`.
2587
+
2588
+ ```ts
2589
+ import { cartridge, type CartridgeConfig } from "@jgengine/shell/cartridge";
2590
+ import { standardCartridgePanels } from "@/components/ui/cartridge-panels";
2591
+
2592
+ export const config: CartridgeConfig = {
2593
+ name, seed, panels: standardCartridgePanels, assets, entitySprites,
2594
+ flow: { start: "gate", countdownSeconds: 3, restart: true }, // press-to-start → 3-2-1 → playing; "restart" command
2595
+ player: { kind, health, walkSpeed }, // compiled into content + spawned per player
2596
+ enemies: { id: { label, health, walkSpeed, xp, contact: { damage, intervalSeconds }, behavior: "chase" | "none" } },
2597
+ combat: { contactRadius },
2598
+ spawning: { director: SpawnDirectorConfig, placement: { kind: "ring", radius } },
2599
+ weapons: { id: { kind: "projectile" | "orbit" | "pulse" | "custom", damage, cooldownMs, maxLevel, ... } },
2600
+ progression: { xp: Curve, maxLevel, draft: { choices, upgrades } },
2601
+ fields: { magnetRadius, damageMultiplier }, // named run-scalars upgrades mutate
2602
+ xpGems: { collectRadius, pullSpeed, rarityThresholds, defaultRarity },
2603
+ rules: { win: { kind: "survive", seconds }, lose: { kind: "playerDeath" } | { kind: "custom", check }, killLeaderboardStat },
2604
+ world, physics, camera, worldItem, theme, hud, screens, // screens: start/win/lose
2605
+ };
2606
+ export const game = cartridge(config);
2607
+ ```
2608
+
2609
+ The run is a phase machine every game gets for free: `start → countdown → playing → won | lost`. `flow.start: "gate"` renders `screens.start` (TitleScreen binding) until begin; `flow.countdownSeconds` renders the big-number countdown; `flow.restart: true` registers a `restart` command that resets the run in place, clears cartridge entities, and restores player stats — no hand-rolled session store, phase union, or reset function. `run.playingSeconds` is the run clock (drives the survive rule and the timer panel), so pauses and pre-game phases never skew timing.
2610
+
2611
+ ## Core surface (`@jgengine/core/cartridge/`)
2612
+
2613
+ - `@jgengine/core/cartridge/spec` — the `CartridgeSpec` types, `Leveled` values (`number | { base, perLevel, min?, max? } | { table }` resolved by `leveled(value, level)`), `WASD_KEYBINDS` (the default when `input` is omitted).
2614
+ - `@jgengine/core/cartridge/runtime` — `createCartridge(spec)` → `{ content, loop, run(ctx), weaponKit(ctx), chooseUpgrade(ctx, offerId) }`. Owns the run store (outcome/kills/fields/weapon levels, subscribe/notify), spawn director advance + ring placement, chase + contact-damage enemies (terrain-grounded), the three weapon archetypes with auto-targeting, magnet xp-gem pickups feeding `leveling()`, pause-and-draft level-ups, kill → gem drop + leaderboard, and win/lose rules.
2615
+ - `@jgengine/core/cartridge/validate` — `validateCartridge(spec)` → problem list: spawn waves must reference declared enemies, upgrades must reference declared weapons/fields, stacks can't exceed weapon `maxLevel`, tuning must be positive, rarity thresholds descending. The game's test asserts it returns `[]`.
2616
+
2617
+ Weapon archetypes: `projectile` (auto-target nearest in `range`, travel-time bolt fx), `orbit` (leveled blade count/radius sweeping `hitRadius`), `pulse` (radial AoE with linear falloff and an expanding-ring fx). Upgrade effects: `weaponLevel` (+1 level per stack), `statBonus` (+max and +current on pick), `fieldAdd` / `fieldMultiply` (recomputed from stacks). Weapon damage is multiplied by the `damageMultiplier` field when declared.
2618
+
2619
+ ## Presentation (shell + registry)
2379
2620
 
2380
- **Named spawn/respawn points** (`game/spawnPoints`)`createSpawnPoints()`: `record(id, { x, y, z, rotationY? })` names a point (level bounds, team spawns, checkpoints), `get(id)`/`list()` read them back, `respawn(entities, entityId, spawnId)` teleports an existing entity to a recorded point via `setPose` in one call the id-keyed alternative to threading raw coordinates through respawn logic by hand.
2621
+ `hud.panels` is a schemaitems `vital | xp | timer | score | abilityBar | component`, anchored via the movable HUD layout; `screens.win/lose` render the results/death screens; the upgrade-draft modal appears whenever a draft is pending. The shell renders the frame and injects concrete components through the `panels` seam `standardCartridgePanels` (registry `cartridge-panels`) binds the jgengine UI kit; swap any binding for a custom component without touching the engine. Weapon fx render engine-side from the run store's bolt/pulse feeds (`fxColor`/`fxEmissive` per weapon). `theme` is the `--jg-*` CSS var block — hand-pick it or derive from a few seed colors with `deriveJgTheme` (registry `jg-theme`).
2622
+
2623
+ ## Escape hatches — bespoke logic without leaving the cartridge
2624
+
2625
+ - `weapons.<id> = { kind: "custom", fire(ctx, run, args) }` — cooldown/level/damage handled for you; the fire shape is yours.
2626
+ - `spawning.placement = { kind: "custom", position(ctx, run) }`.
2627
+ - `rules.win = { kind: "custom", check(ctx, run) }`.
2628
+ - `progression.draft.upgrades[].effect = { kind: "custom", apply(ctx, run, stacks) }`.
2629
+ - `systems: [(ctx, run, dt) => ...]` — extra per-tick simulation after the built-ins, only while playing.
2630
+ - `hud` items `{ kind: "component", Component }` — arbitrary React panels alongside the schema ones.
2631
+
2632
+ A game that is *mostly* bespoke simulation (custom physics, session machines) should keep `defineGame` and write its systems — the cartridge is for games whose shape the archetypes already cover.
2633
+
2634
+ ## Testing
2635
+
2636
+ Cartridge behaviors are engine-tested once (`packages/core/src/cartridge/runtime.test.ts`); the game test is one call plus any game-specific assertions:
2637
+
2638
+ ```ts
2639
+ import { bootCartridge, cartridgeSmokeTest, tickCartridge } from "@jgengine/core/cartridge/testkit";
2640
+ cartridgeSmokeTest(config); // validate + world summary + headless run/spawn/kill/gem smoke
2641
+ ```
2381
2642
 
2382
- **Level sequence** (`game/levelSequence`) `createLevelSequence({ levels: [{ id, config }], retriesPerLevel? })` is a pure, deterministic campaign machine: `start()` enters level 0, `clear()` marks the current level cleared, `advance()` moves to the next (or `"complete"` after the last), `fail()` consumes an attempt and returns `"retry"` while `retriesPerLevel` remain else `"failed"`, `retry()` restarts after a retry-eligible failure. `current()` `{ id, index, config, attempt } | null`; `progress()` → `{ index, total, cleared }`; `reset()` rewinds to idle. Mirrors the reducer style of `game/race` and `ai/spawnDirector` — a level-select/roguelike-run campaign shell without hand-rolling the state machine per game.
2643
+ `bootCartridge`/`tickCartridge` build a headless `GameContext` and drive the loop (auto-choosing drafts) for custom assertions see `Games/swarm-survivor/src/game/cartridge.test.ts`. The testkit imports `bun:test`; import it from test files only.