@jgengine/core 0.14.0 → 0.15.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 (214) hide show
  1. package/CHANGELOG.md +500 -1
  2. package/dist/ai/difficulty.d.ts +154 -0
  3. package/dist/ai/difficulty.js +194 -0
  4. package/dist/ai/driver.d.ts +107 -0
  5. package/dist/ai/driver.js +175 -0
  6. package/dist/ai/flock.js +13 -23
  7. package/dist/ai/laneSelect.js +2 -1
  8. package/dist/ai/mobBrain.js +2 -1
  9. package/dist/ai/waveRunner.d.ts +81 -0
  10. package/dist/ai/waveRunner.js +104 -0
  11. package/dist/audio/musicState.d.ts +47 -0
  12. package/dist/audio/musicState.js +38 -0
  13. package/dist/audio/stepCadence.d.ts +47 -0
  14. package/dist/audio/stepCadence.js +29 -0
  15. package/dist/audio/synth.d.ts +12 -0
  16. package/dist/authoring.d.ts +6 -4
  17. package/dist/authoring.js +6 -4
  18. package/dist/combat/castRunner.d.ts +13 -1
  19. package/dist/combat/castRunner.js +5 -1
  20. package/dist/combat/comboMeter.d.ts +140 -0
  21. package/dist/combat/comboMeter.js +215 -0
  22. package/dist/combat/comboPoints.d.ts +11 -0
  23. package/dist/combat/comboPoints.js +11 -2
  24. package/dist/combat/damageResolution.d.ts +1 -1
  25. package/dist/combat/defensiveWindow.d.ts +5 -0
  26. package/dist/combat/effects.d.ts +24 -0
  27. package/dist/combat/effects.js +6 -1
  28. package/dist/combat/projectiles.d.ts +6 -0
  29. package/dist/combat/projectiles.js +2 -2
  30. package/dist/combat/regenShield.d.ts +40 -2
  31. package/dist/combat/regenShield.js +66 -18
  32. package/dist/combat/resourcePool.d.ts +19 -0
  33. package/dist/combat/resourcePool.js +20 -2
  34. package/dist/combat/shotOrigin.d.ts +5 -0
  35. package/dist/combat/statusApplication.d.ts +1 -1
  36. package/dist/combat/statusApplication.js +2 -1
  37. package/dist/combat/statusEffectView.d.ts +56 -0
  38. package/dist/combat/statusEffectView.js +52 -0
  39. package/dist/combat.d.ts +4 -2
  40. package/dist/combat.js +2 -0
  41. package/dist/devtools/fallbackSeams.d.ts +3 -2
  42. package/dist/devtools/fallbackSeams.js +1 -1
  43. package/dist/devtools/textureErrors.d.ts +40 -0
  44. package/dist/devtools/textureErrors.js +68 -0
  45. package/dist/economy/shopStock.d.ts +110 -0
  46. package/dist/economy/shopStock.js +157 -0
  47. package/dist/economy/wallet.d.ts +6 -0
  48. package/dist/economy/wallet.js +1 -0
  49. package/dist/game/achievements.d.ts +80 -0
  50. package/dist/game/achievements.js +124 -0
  51. package/dist/game/cameraConfig.d.ts +7 -0
  52. package/dist/game/codex.d.ts +60 -0
  53. package/dist/game/codex.js +89 -0
  54. package/dist/game/credits.d.ts +36 -0
  55. package/dist/game/credits.js +0 -0
  56. package/dist/game/defineGame.d.ts +28 -3
  57. package/dist/game/defineGame.js +5 -0
  58. package/dist/game/dialogueGraph.d.ts +116 -0
  59. package/dist/game/dialogueGraph.js +103 -0
  60. package/dist/game/eventTicker.d.ts +95 -0
  61. package/dist/game/eventTicker.js +91 -0
  62. package/dist/game/events.d.ts +24 -5
  63. package/dist/game/events.js +18 -0
  64. package/dist/game/gamePhase.d.ts +1 -1
  65. package/dist/game/gamePhase.js +1 -1
  66. package/dist/game/leaderboardRank.d.ts +71 -0
  67. package/dist/game/leaderboardRank.js +76 -0
  68. package/dist/game/lootTable.d.ts +10 -1
  69. package/dist/game/lootTable.js +4 -2
  70. package/dist/game/notifications.d.ts +57 -0
  71. package/dist/game/notifications.js +107 -0
  72. package/dist/game/playableGame.d.ts +39 -2
  73. package/dist/game/quest.d.ts +26 -0
  74. package/dist/game/quest.js +31 -0
  75. package/dist/game/runDraft.js +2 -1
  76. package/dist/game/saveSlots.d.ts +102 -0
  77. package/dist/game/saveSlots.js +136 -0
  78. package/dist/game/talentTreeView.d.ts +106 -0
  79. package/dist/game/talentTreeView.js +114 -0
  80. package/dist/game/worldItem.js +2 -1
  81. package/dist/gameplay.d.ts +15 -4
  82. package/dist/gameplay.js +13 -2
  83. package/dist/i18n/i18n.d.ts +51 -0
  84. package/dist/i18n/i18n.js +82 -0
  85. package/dist/input/axisInput.d.ts +1 -1
  86. package/dist/input/axisInput.js +18 -2
  87. package/dist/input/rebindSession.d.ts +141 -0
  88. package/dist/input/rebindSession.js +235 -0
  89. package/dist/input/touchControlsMode.d.ts +16 -0
  90. package/dist/input/touchControlsMode.js +23 -0
  91. package/dist/input/touchScheme.d.ts +30 -1
  92. package/dist/input/touchScheme.js +31 -1
  93. package/dist/interaction/promptRegistry.d.ts +78 -0
  94. package/dist/interaction/promptRegistry.js +130 -0
  95. package/dist/inventory/storageTier.js +3 -2
  96. package/dist/meta/changelog.d.ts +1 -1
  97. package/dist/meta/changelog.js +145 -1
  98. package/dist/movement/movementModel.d.ts +26 -3
  99. package/dist/movement/movementModel.js +121 -15
  100. package/dist/movement/playerMovement.js +161 -25
  101. package/dist/physics/drivableVehicle.js +3 -1
  102. package/dist/physics/kinematicVehicle.d.ts +56 -1
  103. package/dist/physics/kinematicVehicle.js +99 -10
  104. package/dist/physics/vehicleObstacles.js +19 -0
  105. package/dist/random/resolveRng.d.ts +14 -0
  106. package/dist/random/resolveRng.js +31 -0
  107. package/dist/render/postProcessing.d.ts +12 -1
  108. package/dist/runtime/cameraDirector.d.ts +2 -2
  109. package/dist/runtime/context/combat.js +8 -0
  110. package/dist/runtime/gameContext.js +6 -1
  111. package/dist/runtime/gameContextTypes.d.ts +18 -0
  112. package/dist/runtime/inputSnapshot.d.ts +10 -1
  113. package/dist/runtime/inputSnapshot.js +13 -1
  114. package/dist/runtime/runtimeSave.d.ts +3 -3
  115. package/dist/runtime/runtimeSave.js +11 -2
  116. package/dist/scene/assetDiagnostics.js +2 -2
  117. package/dist/scene/autoTarget.js +2 -1
  118. package/dist/scene/captureCheck.js +3 -2
  119. package/dist/scene/entityStore.d.ts +4 -1
  120. package/dist/scene/entityStore.js +3 -0
  121. package/dist/scene/objectStore.d.ts +10 -0
  122. package/dist/scene/objectStore.js +29 -6
  123. package/dist/scene/sceneKinds.d.ts +18 -0
  124. package/dist/scene/sceneKinds.js +19 -0
  125. package/dist/scene/sequenceDirector.d.ts +110 -0
  126. package/dist/scene/sequenceDirector.js +146 -0
  127. package/dist/session/roles.js +3 -2
  128. package/dist/settings/settingsModel.d.ts +1 -0
  129. package/dist/settings/settingsModel.js +1 -0
  130. package/dist/stats/rollCheck.js +4 -2
  131. package/dist/time/dayNightCycle.d.ts +114 -0
  132. package/dist/time/dayNightCycle.js +208 -0
  133. package/dist/time/timerSet.d.ts +132 -0
  134. package/dist/time/timerSet.js +243 -0
  135. package/dist/ui/accessibility.d.ts +51 -0
  136. package/dist/ui/accessibility.js +83 -0
  137. package/dist/ui/coachMarks.d.ts +99 -0
  138. package/dist/ui/coachMarks.js +126 -0
  139. package/dist/ui/floatingText.d.ts +114 -0
  140. package/dist/ui/floatingText.js +203 -0
  141. package/dist/ui/modalStack.d.ts +143 -0
  142. package/dist/ui/modalStack.js +160 -0
  143. package/dist/ui/objectiveBanner.d.ts +123 -0
  144. package/dist/ui/objectiveBanner.js +162 -0
  145. package/dist/ui/photoMode.d.ts +32 -0
  146. package/dist/ui/photoMode.js +51 -0
  147. package/dist/ui/radialMenu.d.ts +47 -0
  148. package/dist/ui/radialMenu.js +74 -0
  149. package/dist/ui/screenMarkers.d.ts +128 -0
  150. package/dist/ui/screenMarkers.js +118 -0
  151. package/dist/ui.d.ts +8 -0
  152. package/dist/ui.js +8 -0
  153. package/dist/vfx/cameraShake.d.ts +97 -0
  154. package/dist/vfx/cameraShake.js +136 -0
  155. package/dist/vfx/damageDirection.d.ts +102 -0
  156. package/dist/vfx/damageDirection.js +188 -0
  157. package/dist/vfx/particles.d.ts +103 -0
  158. package/dist/vfx/particles.js +256 -0
  159. package/dist/vfx/screenEffects.d.ts +149 -0
  160. package/dist/vfx/screenEffects.js +191 -0
  161. package/dist/world/authoredObjects.d.ts +18 -0
  162. package/dist/world/authoredObjects.js +21 -0
  163. package/dist/world/authoredSpawn.js +19 -0
  164. package/dist/world/buildingLots.d.ts +54 -11
  165. package/dist/world/buildingLots.js +116 -30
  166. package/dist/world/cityBlocks.d.ts +45 -0
  167. package/dist/world/cityBlocks.js +159 -0
  168. package/dist/world/cityContent.d.ts +46 -2
  169. package/dist/world/cityContent.js +160 -2
  170. package/dist/world/cityGenerator.d.ts +261 -23
  171. package/dist/world/cityGenerator.js +643 -33
  172. package/dist/world/cityGeometry.d.ts +20 -0
  173. package/dist/world/cityGeometry.js +91 -0
  174. package/dist/world/cityKind.js +1 -44
  175. package/dist/world/fastTravel.d.ts +64 -0
  176. package/dist/world/fastTravel.js +108 -0
  177. package/dist/world/features.d.ts +16 -0
  178. package/dist/world/features.js +2 -0
  179. package/dist/world/grounded.d.ts +11 -0
  180. package/dist/world/grounded.js +12 -0
  181. package/dist/world/mapAnnotations.d.ts +75 -0
  182. package/dist/world/mapAnnotations.js +139 -0
  183. package/dist/world/markers.js +1 -0
  184. package/dist/world/resourceNode.d.ts +1 -1
  185. package/dist/world/resourceNode.js +2 -1
  186. package/dist/world/roads.d.ts +294 -5
  187. package/dist/world/roads.js +1262 -21
  188. package/dist/world/sharedAnnotations.d.ts +84 -0
  189. package/dist/world/sharedAnnotations.js +76 -0
  190. package/dist/world/spawnOverride.d.ts +37 -0
  191. package/dist/world/spawnOverride.js +50 -0
  192. package/dist/world/streetGenerator.d.ts +60 -11
  193. package/dist/world/streetGenerator.js +1786 -129
  194. package/dist/world/terrain.d.ts +1 -0
  195. package/dist/world/terrain.js +2 -0
  196. package/dist/world/waypoints.d.ts +63 -0
  197. package/dist/world/waypoints.js +137 -0
  198. package/dist/world.d.ts +17 -2
  199. package/dist/world.js +17 -2
  200. package/package.json +1 -1
  201. package/skills/ce-handoff/SKILL.md +149 -0
  202. package/skills/game-design/SKILL.md +2 -0
  203. package/skills/jgengine/SKILL.md +3 -1
  204. package/skills/jgengine/api.md +17 -7
  205. package/skills/jgengine/capabilities.md +12 -0
  206. package/skills/jgengine/recipes/minimal-game.md +7 -4
  207. package/skills/jgengine-combat/api.md +40 -4
  208. package/skills/jgengine-combat/capabilities.md +12 -0
  209. package/skills/jgengine-combat/reference.md +2 -0
  210. package/skills/jgengine-gameplay/api.md +212 -11
  211. package/skills/jgengine-gameplay/capabilities.md +66 -1
  212. package/skills/jgengine-world/api.md +397 -11
  213. package/skills/jgengine-world/capabilities.md +123 -2
  214. package/skills/jgengine-world/reference.md +15 -1
@@ -1,9 +1,9 @@
1
1
  import { steerYaw } from "../movement/steering.js";
2
2
  import { DEFAULT_GRIP_CURVE, sampleGripCurve } from "./vehicleBody.js";
3
- export function createKinematicVehicle(tuning, options = {}) {
4
- const grip = tuning.grip ?? DEFAULT_GRIP_CURVE;
5
- const chassis = tuning.chassis;
6
- const rollingResistance = tuning.rollingResistance ?? 0;
3
+ export function createKinematicVehicle(initialTuning, options = {}) {
4
+ // Mutable so `retune` can swap the whole block without disturbing the integrator's state; every
5
+ // read below is per-tick rather than captured at construction for the same reason.
6
+ let tuning = initialTuning;
7
7
  const surfaceFriction = options.surfaceFriction ?? (() => 1);
8
8
  const dragAt = options.dragAt ?? (() => 0);
9
9
  const clampMove = options.clampMove;
@@ -21,11 +21,17 @@ export function createKinematicVehicle(tuning, options = {}) {
21
21
  let previousForwardSpeed = 0;
22
22
  let bodyPitch = 0;
23
23
  let bodyRoll = 0;
24
+ let airOffset = 0;
25
+ let verticalVelocity = 0;
24
26
  function forward() {
25
27
  return [Math.sin(heading), Math.cos(heading)];
26
28
  }
27
29
  return {
28
30
  tick(dt, axis, modifiers) {
31
+ const grip = tuning.grip ?? DEFAULT_GRIP_CURVE;
32
+ const chassis = tuning.chassis;
33
+ const rollingResistance = tuning.rollingResistance ?? 0;
34
+ const hopGravity = tuning.hopGravity ?? 0;
29
35
  const topSpeed = tuning.topSpeed * (modifiers?.topSpeedScale ?? 1);
30
36
  const engineAccel = tuning.engineAccel * (modifiers?.accelScale ?? 1);
31
37
  const turnRate = tuning.turnRate * (modifiers?.turnRateScale ?? 1);
@@ -50,6 +56,20 @@ export function createKinematicVehicle(tuning, options = {}) {
50
56
  yawRate += (targetYaw - yawRate) * yawResponse;
51
57
  heading += yawRate * dt;
52
58
  }
59
+ // Handbrake rear-lock oversteer: pure grip bleed leaves a bicycle car going mostly straight
60
+ // while sliding. Extra yaw from steer×handbrake is what makes drifts initiate and hold
61
+ // (GTA/NFS-style street feel) without needing a dual-axle tire model.
62
+ if (axis.handbrake > 0.05 && Math.abs(speed) > HANDBRAKE_OVERSTEER_MIN_SPEED && Math.abs(axis.steer) > 0.02) {
63
+ const speedFactor = Math.min(1, Math.abs(speed) / HANDBRAKE_OVERSTEER_REF_SPEED);
64
+ const oversteerYaw = -Math.sign(speed === 0 ? 1 : speed) *
65
+ axis.steer *
66
+ axis.handbrake *
67
+ HANDBRAKE_OVERSTEER_YAW *
68
+ speedFactor;
69
+ // Integrate immediately so this tick's heading (and next-frame velocity basis) rotate with the slide.
70
+ heading += oversteerYaw * dt;
71
+ yawRate += oversteerYaw * 0.65;
72
+ }
53
73
  const [fx, fz] = forward();
54
74
  let accel = 0;
55
75
  let tractionLimited = false;
@@ -81,6 +101,15 @@ export function createKinematicVehicle(tuning, options = {}) {
81
101
  const normalizedRpm = (rpm - powertrain.idleRpm) / Math.max(1, powertrain.redlineRpm - powertrain.idleRpm);
82
102
  const torque = sampleGripCurve(powertrain.torqueCurve, normalizedRpm);
83
103
  torqueFactor = torque * (shiftRemaining > 0 ? 0.32 : 1);
104
+ // Launch clutch floor: at crawl speeds the idle end of a torque curve under-reads what a
105
+ // clutched street car actually puts down. Fade the floor out by {@link LAUNCH_TORQUE_FADE_SPEED}
106
+ // so mid-range power still comes from the curve.
107
+ if (axis.throttle > 0) {
108
+ const launchBlend = 1 - Math.min(1, Math.abs(speed) / LAUNCH_TORQUE_FADE_SPEED);
109
+ if (launchBlend > 0) {
110
+ torqueFactor = Math.max(torqueFactor, LAUNCH_TORQUE_FLOOR * launchBlend + torqueFactor * (1 - launchBlend));
111
+ }
112
+ }
84
113
  }
85
114
  if (axis.throttle > 0 && speed < topSpeed) {
86
115
  accel += chassis === undefined
@@ -90,16 +119,20 @@ export function createKinematicVehicle(tuning, options = {}) {
90
119
  if (axis.brake > 0) {
91
120
  if (speed > 0.2) {
92
121
  const abs = tuning.dynamics?.abs ?? 0;
93
- const brakeScale = Math.max(0.15, 1 - abs * Math.max(0, Math.abs(speed) / Math.max(1, topSpeed) - 0.72));
94
- absActive = brakeScale < 0.999;
122
+ const brakeScale = Math.max(0.15, 1 - abs * Math.max(0, Math.abs(speed) / Math.max(1, topSpeed) - 0.72)) *
123
+ (modifiers?.brakeScale ?? 1);
124
+ absActive = brakeScale < 0.999 * (modifiers?.brakeScale ?? 1);
95
125
  accel -= chassis === undefined
96
126
  ? axis.brake * tuning.brakeAccel * brakeScale
97
127
  : (axis.brake * chassis.brakeForce * brakeScale) / chassis.massKg;
98
128
  }
99
129
  else if (speed > -tuning.reverseSpeed) {
130
+ // Reverse is intentionally softer than forward — full engineForce made performance cars
131
+ // hit reverse top in ~2 s and felt like a second highway gear.
132
+ const reverseScale = chassis?.reverseForceScale ?? tuning.reverseForceScale ?? DEFAULT_REVERSE_FORCE_SCALE;
100
133
  accel -= chassis === undefined
101
- ? axis.brake * engineAccel
102
- : (axis.brake * chassis.engineForce * (modifiers?.accelScale ?? 1)) / chassis.massKg;
134
+ ? axis.brake * engineAccel * reverseScale
135
+ : (axis.brake * chassis.engineForce * reverseScale * (modifiers?.accelScale ?? 1)) / chassis.massKg;
103
136
  }
104
137
  }
105
138
  const traction = tuning.dynamics?.tractionControl ?? 0;
@@ -121,11 +154,27 @@ export function createKinematicVehicle(tuning, options = {}) {
121
154
  // never push the car backwards.
122
155
  if (chassis !== undefined && dt > 0 && Math.abs(speed) > 1e-4) {
123
156
  let resistDecel = ROLLING_RESISTANCE_COEFFICIENT * GRAVITY;
124
- if (axis.throttle <= 0 && tuning.powertrain !== undefined) {
157
+ // Engine braking is a coast/lift-off load only. Applying it while the brake axis is driving
158
+ // reverse (speed ≤ 0.2 + brake) would fight reverse accel and leave cars crawling backwards.
159
+ const reverseDriving = axis.brake > 0 && speed <= 0.2;
160
+ if (axis.throttle <= 0 && !reverseDriving && tuning.powertrain !== undefined) {
125
161
  resistDecel += ENGINE_BRAKE_COEFFICIENT * GRAVITY * gearLeverage;
126
162
  }
127
163
  accel -= Math.sign(speed) * Math.min(resistDecel, Math.abs(speed) / dt);
128
164
  }
165
+ // Arcade coast road-load: a flat deceleration while genuinely coasting — no throttle and no
166
+ // brake — capped at |speed|/dt so it can never drag the vehicle backwards through zero. The
167
+ // `chassis` path above derives the same feel from mass and gearing, so the two are mutually
168
+ // exclusive by construction rather than by convention.
169
+ const coastDeceleration = tuning.coastDeceleration ?? 0;
170
+ if (chassis === undefined &&
171
+ coastDeceleration > 0 &&
172
+ dt > 0 &&
173
+ axis.throttle <= 0 &&
174
+ axis.brake <= 0 &&
175
+ Math.abs(speed) > 1e-4) {
176
+ accel -= Math.sign(speed) * Math.min(coastDeceleration, Math.abs(speed) / dt);
177
+ }
129
178
  vx += fx * accel * dt;
130
179
  vz += fz * accel * dt;
131
180
  let forwardSpeed = vx * fx + vz * fz;
@@ -136,7 +185,8 @@ export function createKinematicVehicle(tuning, options = {}) {
136
185
  const downforce = 1 + (tuning.dynamics?.downforce ?? 0) * Math.min(1, Math.abs(forwardSpeed) / Math.max(1, topSpeed));
137
186
  const gripValue = sampleGripCurve(grip, slip) * surface * handbrakeFactor * downforce;
138
187
  const keep = Math.max(0, 1 - gripValue * tuning.gripStrength * dt);
139
- const stability = tuning.dynamics?.stabilityControl ?? 0;
188
+ // ESC fights intentional slides — ease it off while the handbrake is held so drift stays steerable.
189
+ const stability = (tuning.dynamics?.stabilityControl ?? 0) * (1 - 0.85 * axis.handbrake);
140
190
  let keptLateral = lateralSpeed * keep * Math.max(0, 1 - stability * Math.abs(axis.steer) * dt);
141
191
  if (chassis !== undefined) {
142
192
  // Friction budget (#1051): total tire force F = μeff * m * g, with μeff = tireGrip * gripValue
@@ -212,6 +262,17 @@ export function createKinematicVehicle(tuning, options = {}) {
212
262
  const targetRoll = clampSigned(-yawRate * forwardSpeed * (tuning.dynamics?.bodyRollFactor ?? 0) * comScale, tuning.dynamics?.maxBodyRoll ?? 0.16);
213
263
  bodyPitch += (targetPitch - bodyPitch) * attitudeResponse;
214
264
  bodyRoll += (targetRoll - bodyRoll) * attitudeResponse;
265
+ // Hop/air state is a pure vertical offset above the ground plane, deliberately independent of
266
+ // the planar sim: a hopping vehicle still steers, still slides, and still follows the terrain
267
+ // underneath it. Without `hopGravity` there is no state to integrate and `airOffset` stays 0.
268
+ if (hopGravity > 0 && (airOffset > 0 || verticalVelocity > 0)) {
269
+ verticalVelocity -= hopGravity * dt;
270
+ airOffset += verticalVelocity * dt;
271
+ if (airOffset <= 0) {
272
+ airOffset = 0;
273
+ verticalVelocity = 0;
274
+ }
275
+ }
215
276
  return {
216
277
  position: [x, y, z],
217
278
  heading,
@@ -229,6 +290,8 @@ export function createKinematicVehicle(tuning, options = {}) {
229
290
  wheelspin,
230
291
  bodyPitch,
231
292
  bodyRoll,
293
+ airOffset,
294
+ airborne: airOffset > 0,
232
295
  };
233
296
  },
234
297
  pose: () => ({ position: [x, y, z], heading }),
@@ -237,6 +300,15 @@ export function createKinematicVehicle(tuning, options = {}) {
237
300
  vx *= factor;
238
301
  vz *= factor;
239
302
  },
303
+ hop(impulse) {
304
+ if ((tuning.hopGravity ?? 0) <= 0 || airOffset > 0 || verticalVelocity > 0)
305
+ return false;
306
+ verticalVelocity = impulse;
307
+ return true;
308
+ },
309
+ retune(next) {
310
+ tuning = next;
311
+ },
240
312
  resetTo(position, nextHeading) {
241
313
  x = position[0];
242
314
  y = position[1];
@@ -252,11 +324,28 @@ export function createKinematicVehicle(tuning, options = {}) {
252
324
  previousForwardSpeed = 0;
253
325
  bodyPitch = 0;
254
326
  bodyRoll = 0;
327
+ airOffset = 0;
328
+ verticalVelocity = 0;
255
329
  },
256
330
  };
257
331
  }
258
332
  /** Gravitational acceleration, m/s², for the chassis friction budget and weight-transfer ratio (#1051). */
259
333
  const GRAVITY = 9.81;
334
+ /** Default reverse drive force as a fraction of forward `engineForce` / `engineAccel`. */
335
+ export const DEFAULT_REVERSE_FORCE_SCALE = 0.48;
336
+ /**
337
+ * Minimum torque-curve factor under throttle near standstill (launch clutch). Fades out by
338
+ * {@link LAUNCH_TORQUE_FADE_SPEED} so it only fills the weak idle end of the curve.
339
+ */
340
+ const LAUNCH_TORQUE_FLOOR = 0.9;
341
+ /** Forward speed (world units/s) by which the launch torque floor has fully faded. */
342
+ const LAUNCH_TORQUE_FADE_SPEED = 9;
343
+ /** Minimum |forward speed| before handbrake oversteer yaw kicks in. */
344
+ const HANDBRAKE_OVERSTEER_MIN_SPEED = 2.5;
345
+ /** Speed at which handbrake oversteer reaches full strength. */
346
+ const HANDBRAKE_OVERSTEER_REF_SPEED = 12;
347
+ /** Peak extra yaw rate (rad/s) from full steer + full handbrake at {@link HANDBRAKE_OVERSTEER_REF_SPEED}. */
348
+ const HANDBRAKE_OVERSTEER_YAW = 3.1;
260
349
  /**
261
350
  * Tyre rolling-resistance coefficient μ_rr (#1051). The always-present coast force is `μ_rr · m · g`
262
351
  * (the road-load floor) — a little high vs. a real ~0.013 tyre so a lifted-off car settles to a stop
@@ -29,6 +29,7 @@ export function createVehicleObstacleClamp(options) {
29
29
  // Grow-only scratch of Y-normalised solids, reused across ticks so a sustained crash allocates nothing
30
30
  // after warm-up. Per-clamp (closure-local) so concurrent cars never share it.
31
31
  const scratch = [];
32
+ const offsetScratch = [];
32
33
  let pending = null;
33
34
  function vehicleView(source) {
34
35
  const n = source.length;
@@ -43,6 +44,24 @@ export function createVehicleObstacleClamp(options) {
43
44
  entry.position[1] = CAR_BODY_MID_Y;
44
45
  entry.position[2] = from.position[2];
45
46
  entry.halfExtents = from.halfExtents;
47
+ // Keep the collider's XZ offset (a fitted body centred away from the placement point) but zero
48
+ // its Y: with the body pinned to CAR_BODY_MID_Y a vertical offset would push the box out of the
49
+ // stepper's pedestrian-span gate and the car would drive through a tall building's collider.
50
+ const fromOffset = from.offset;
51
+ if (fromOffset !== undefined && (fromOffset[0] !== 0 || fromOffset[2] !== 0)) {
52
+ let flat = offsetScratch[i];
53
+ if (flat === undefined) {
54
+ flat = [0, 0, 0];
55
+ offsetScratch[i] = flat;
56
+ }
57
+ flat[0] = fromOffset[0];
58
+ flat[2] = fromOffset[2];
59
+ entry.offset = flat;
60
+ }
61
+ else {
62
+ entry.offset = undefined;
63
+ }
64
+ entry.boxes = from.boxes;
46
65
  }
47
66
  if (scratch.length > n)
48
67
  scratch.length = n;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolves an optional injected `[0,1)` generator, falling back to `Math.random` — and says so, once
3
+ * per call site.
4
+ *
5
+ * Every primitive that rolls dice takes an `rng`, but the default was silent: a caller who forgot to
6
+ * pass `ctx.rng` got a simulation that cannot replay, lockstep, roll back, or be pinned by a test,
7
+ * with nothing on screen or in the log to say why. The fallback stays (a portable helper called
8
+ * outside a game context is a real use), but it is now loud instead of invisible.
9
+ *
10
+ * @capability resolve-rng resolve an optional injected RNG, warning once per site when it falls back to `Math.random`
11
+ */
12
+ export declare function resolveRng(provided: (() => number) | undefined, site: string): () => number;
13
+ /** Clears the warned-site memo. Tests only — the warning is once-per-site for the life of the process. */
14
+ export declare function resetRngFallbackWarnings(): void;
@@ -0,0 +1,31 @@
1
+ import { isProductionEnvironment } from "../devtools/saveEndpoint.js";
2
+ const warned = new Set();
3
+ /** Narrow structural view of the ambient console — core never pulls in the DOM lib. */
4
+ function consoleWarn(message) {
5
+ const host = globalThis;
6
+ host.console?.warn?.(message);
7
+ }
8
+ /**
9
+ * Resolves an optional injected `[0,1)` generator, falling back to `Math.random` — and says so, once
10
+ * per call site.
11
+ *
12
+ * Every primitive that rolls dice takes an `rng`, but the default was silent: a caller who forgot to
13
+ * pass `ctx.rng` got a simulation that cannot replay, lockstep, roll back, or be pinned by a test,
14
+ * with nothing on screen or in the log to say why. The fallback stays (a portable helper called
15
+ * outside a game context is a real use), but it is now loud instead of invisible.
16
+ *
17
+ * @capability resolve-rng resolve an optional injected RNG, warning once per site when it falls back to `Math.random`
18
+ */
19
+ export function resolveRng(provided, site) {
20
+ if (provided !== undefined)
21
+ return provided;
22
+ if (!warned.has(site) && !isProductionEnvironment()) {
23
+ warned.add(site);
24
+ consoleWarn(`[jgengine:rng] ${site} fell back to Math.random — this run cannot replay, lockstep, roll back, or be pinned by a test. Pass \`ctx.rng\` (the per-world seeded stream), or \`seededRng(seed)\` outside a game context.`);
25
+ }
26
+ return Math.random;
27
+ }
28
+ /** Clears the warned-site memo. Tests only — the warning is once-per-site for the life of the process. */
29
+ export function resetRngFallbackWarnings() {
30
+ warned.clear();
31
+ }
@@ -47,7 +47,13 @@ export interface DofConfig {
47
47
  maxBlur?: number;
48
48
  }
49
49
  /**
50
- * Declarative post-processing chain (RenderPass → AO → Bloom → tone-map output →
50
+ * Edge antialiasing for the post chain. SMAA runs in linear-sRGB before the output
51
+ * tone-map and cleans alpha-tested foliage/particles that MSAA samples alone leave crawling.
52
+ * `"msaa"` keeps only the render-target multisample resolve; `false` disables AA stages.
53
+ */
54
+ export type PostAaMode = "smaa" | "msaa" | false;
55
+ /**
56
+ * Declarative post-processing chain (RenderPass → AO → Bloom → SMAA → tone-map output →
51
57
  * Grade). Present on a game means the shell mounts an `EffectComposer` and owns
52
58
  * the render; absent means the renderer draws directly (unchanged). Each stage is
53
59
  * a config object, `false` to skip, or omitted for its default. Pure data — no
@@ -60,6 +66,11 @@ export interface PostProcessingConfig {
60
66
  toneMapping?: ToneMappingMode;
61
67
  /** Exposure multiplier applied before tone mapping. Default 1. */
62
68
  exposure?: number;
69
+ /**
70
+ * Antialiasing. Default `"smaa"` (plus a light MSAA resolve on the beauty target).
71
+ * Set `"msaa"` to skip the SMAA pass, or `false` for neither.
72
+ */
73
+ aa?: PostAaMode;
63
74
  bloom?: BloomConfig | false;
64
75
  /** Ambient occlusion. Heavier than the other stages — omit or `false` on low-end targets. Default off (omitted). */
65
76
  ao?: AoConfig | false;
@@ -1,6 +1,6 @@
1
1
  import type { ChaseCameraConfig, CinematicCameraConfig } from "../game/playableGame.js";
2
- /** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11). */
3
- export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank">>;
2
+ /** Runtime patch over the static `camera.chase` config — distance/height/fov retuning from gameplay events (#286.11), or a whole driving-feel overlay (speed→FOV, lead, bank, speed shake, drift-lag) applied only while a vehicle is piloted (#1299). */
3
+ export type ChaseCameraTuning = Partial<Pick<ChaseCameraConfig, "distance" | "height" | "lookHeight" | "springDamping" | "fov" | "lead" | "bank" | "shakePerSpeed" | "velocityYaw" | "yawResponse">>;
4
4
  export interface CameraDirector {
5
5
  follow(entityId: string | null): void;
6
6
  /** `undefined` means no runtime override — the shell falls back to the static `playable.camera.followEntityId`. `null` means explicitly follow nothing. */
@@ -34,6 +34,13 @@ export function createCombatSubsystem(d) {
34
34
  statPools: createEntityStatsApi((instanceId) => statsByInstance.get(instanceId)),
35
35
  getStat: weapon.getStat,
36
36
  spatial: combatSpatial,
37
+ resolveSlainIdentity(instanceId) {
38
+ const entity = entities.get(instanceId);
39
+ if (entity === null)
40
+ return null;
41
+ // `entity.name` is the spawn kind/catalog id, matching the `entity.died` event.
42
+ return { catalogId: entity.name, name: entity.name };
43
+ },
37
44
  onLethal(instanceId, lethalCtx) {
38
45
  // Capture identity + onDeath *before* resolveDeath despawns the entity.
39
46
  const dyingEntity = entities.get(instanceId);
@@ -117,6 +124,7 @@ export function createCombatSubsystem(d) {
117
124
  at: [report.at[0], report.at[1], report.at[2]],
118
125
  effect: report.effect,
119
126
  hit: report.hit,
127
+ ballistic: report.ballistic,
120
128
  });
121
129
  },
122
130
  }), ["fireProjectile", "settleProjectile"], signalNotify);
@@ -19,6 +19,7 @@ import { createRuntimeSave } from "./runtimeSave.js";
19
19
  import { isOffline } from "./adapter.js";
20
20
  import { localSaveBackend, memorySaveBackend } from "../game/saveStore.js";
21
21
  import { createSimClock } from "../time/simClock.js";
22
+ import { seededRng } from "../random/rng.js";
22
23
  import { createCameraDirector } from "./cameraDirector.js";
23
24
  import { createInputSnapshot } from "./inputSnapshot.js";
24
25
  import { baselineDescriptors } from "./descriptors/baseline.js";
@@ -32,6 +33,9 @@ export function createGameContext(options) {
32
33
  const { definition, content, player } = options;
33
34
  const now = options.now ?? Date.now;
34
35
  const occluder = options.occluder;
36
+ // Per-world deterministic stream — never Math.random on simulation paths (#1545).
37
+ const rng = options.rng ??
38
+ seededRng(options.seed ?? `jg:${definition.name.trim().length > 0 ? definition.name : "game"}`);
35
39
  const signal = createChangeSignal();
36
40
  let actingUserId = null;
37
41
  const activeUserId = () => actingUserId ?? player.userId;
@@ -60,7 +64,7 @@ export function createGameContext(options) {
60
64
  // --- Shared game services (weapon, feed, features bag, commands, item use) ---
61
65
  const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
62
66
  const feed = createGameFeed(definition.feed);
63
- const lootRegistry = createLootRegistry();
67
+ const lootRegistry = createLootRegistry({ rng });
64
68
  /**
65
69
  * Single feature-activation path (G9): `definition.features` is already the final map after
66
70
  * `defineGame` OR-merged explicit boolean sugar (`features: { quest: true }`) with system-implied
@@ -322,6 +326,7 @@ export function createGameContext(options) {
322
326
  }
323
327
  // --- Assemble public GameContext ---
324
328
  const ctx = {
329
+ rng,
325
330
  scene: {
326
331
  object: sceneObjects,
327
332
  entity: {
@@ -107,6 +107,16 @@ export interface GameContextOptions<TAssetRef extends ModelAssetRef = ModelAsset
107
107
  };
108
108
  now?: () => number;
109
109
  occluder?: (from: EntityPosition, to: EntityPosition) => boolean;
110
+ /**
111
+ * Seed for {@link GameContext.rng}. Defaults to the game name so two contexts of the same
112
+ * definition share a stream unless a host passes a unique seed per world/session.
113
+ */
114
+ seed?: string | number;
115
+ /**
116
+ * Injected `[0,1)` generator for this world. Defaults to a {@link seededRng} from {@link seed}.
117
+ * Never use `Math.random` on simulation paths — pass `ctx.rng` into pure helpers instead.
118
+ */
119
+ rng?: () => number;
110
120
  /** Bind `ctx.game.save` to a pluggable backend (offline/single-player whole-world save). The shell resolves this from `defineGame({ save })`; multiplayer leaves it off (the host persists). */
111
121
  save?: RuntimeSaveOptions;
112
122
  /**
@@ -414,6 +424,14 @@ export interface GameContext {
414
424
  raycastAll(input: SceneRaycastInput): readonly SceneRaycastHit[];
415
425
  };
416
426
  world: GameContextWorld;
427
+ /**
428
+ * Deterministic `[0,1)` stream for this world — loot, AI, combat rolls, and any sim path that
429
+ * needs randomness. Same seed → same sequence. Never fall back to `Math.random` in systems;
430
+ * pass this into pure helpers (`rollCheck`, `autoTarget`, loot tables, …).
431
+ *
432
+ * @capability game-context-rng per-world seeded randomness for replay, lockstep, and multi-world hosts
433
+ */
434
+ rng: () => number;
417
435
  game: {
418
436
  commands: GameContextCommands;
419
437
  events: GameEvents;
@@ -4,12 +4,16 @@ import { type AxisBinding, type AxisRange } from "../input/axisInput.js";
4
4
  export interface InputFrame {
5
5
  held: readonly string[];
6
6
  pointer: PointerAxisState | null;
7
+ /** Analog per-action magnitudes (0..1) for actions driven by a continuous source (virtual joystick, gamepad stick); absent/null means every held action is fully pressed. */
8
+ analog?: Readonly<Record<string, number>> | null;
7
9
  }
8
10
  export interface InputSnapshot {
9
11
  /** Replaces the held-action set for this frame, rolling the previous held set forward for edge detection (#671). Called by the shell before `onTick` each frame; does not bump `ctx.version()` or notify `ctx.subscribe` listeners — per-frame publishes would storm subscribers. */
10
12
  publish(held: readonly string[]): void;
11
13
  /** Replaces the normalized pointer-position state for this frame (#293). Same no-notify contract as `publish`. */
12
14
  publishPointer(state: PointerAxisState | null): void;
15
+ /** Replaces the analog per-action magnitudes for this frame (#1370) — the virtual joystick's continuous vector, split into 0..1 values per movement action. `null` clears back to digital-only. Same no-notify contract as `publish`. */
16
+ publishAnalog(values: Readonly<Record<string, number>> | null): void;
13
17
  isDown(action: string): boolean;
14
18
  /** The held-action set for this frame — an owned, frozen array; the array {@link publish} was called with is never aliased or returned. */
15
19
  held(): readonly string[];
@@ -17,13 +21,18 @@ export interface InputSnapshot {
17
21
  justPressed(action: string): boolean;
18
22
  /** True only on the frame `action` transitions from down to up (#671); mirrors {@link justPressed}. */
19
23
  justReleased(action: string): boolean;
24
+ /** This frame's magnitude for `action`: its published analog value when one exists, else 1 while held / 0 while up. What `axis()` samples per code, so an analog stick steers proportionally where a key is all-or-nothing. */
25
+ value(action: string): number;
26
+ /** The published analog map for this frame, `null` when input is digital-only — an owned, frozen copy. */
27
+ analog(): Readonly<Record<string, number>> | null;
20
28
  /** Pointer position over the play surface, `[-1, 1]` per axis with `+y` down, published by the shell each frame; `null` until the first pointer move. Frozen — an owned copy of what {@link publishPointer} was called with. */
21
29
  pointer(): Readonly<PointerAxisState> | null;
22
30
  /**
23
31
  * Instantaneous analog axis sample against the held-action set and current pointer (#533.7) — bind
24
32
  * throttle/steer/handbrake (or any axes) to *action names*, not raw key codes, since the held set is
25
33
  * semantic actions. Feed the result into an `AxisChannel` for smoothing; unlisted ranges default to
26
- * bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals.
34
+ * bipolar `[-1, 1]`, so pass `{ min: 0, max: 1 }` for one-directional pedals. Actions with a published
35
+ * analog magnitude contribute that value instead of 0/1, so a virtual joystick steers proportionally.
27
36
  */
28
37
  axis<TAxes extends string>(bindings: Record<TAxes, AxisBinding>, ranges?: Partial<Record<TAxes, AxisRange>>): Record<TAxes, number>;
29
38
  }
@@ -4,6 +4,13 @@ export function createInputSnapshot() {
4
4
  let previousHeldSet = new Set();
5
5
  let heldList = [];
6
6
  let pointerState = null;
7
+ let analogValues = null;
8
+ const value = (action) => {
9
+ const analog = analogValues?.[action];
10
+ if (analog !== undefined)
11
+ return analog;
12
+ return heldSet.has(action) ? 1 : 0;
13
+ };
7
14
  return {
8
15
  publish(held) {
9
16
  previousHeldSet = heldSet;
@@ -13,10 +20,15 @@ export function createInputSnapshot() {
13
20
  publishPointer(state) {
14
21
  pointerState = state === null ? null : Object.freeze({ ...state });
15
22
  },
23
+ publishAnalog(values) {
24
+ analogValues = values === null ? null : Object.freeze({ ...values });
25
+ },
16
26
  isDown: (action) => heldSet.has(action),
17
27
  held: () => heldList,
18
28
  pointer: () => pointerState,
19
- axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges),
29
+ value,
30
+ analog: () => analogValues,
31
+ axis: (bindings, ranges) => sampleAxisBindings(bindings, (action) => heldSet.has(action), pointerState, ranges, value),
20
32
  justPressed: (action) => heldSet.has(action) && !previousHeldSet.has(action),
21
33
  justReleased: (action) => !heldSet.has(action) && previousHeldSet.has(action),
22
34
  };
@@ -12,7 +12,7 @@ export interface RuntimeSaveTarget {
12
12
  hydrate(snapshot: WorldSnapshot): void;
13
13
  subscribe(listener: () => void): () => void;
14
14
  }
15
- /** `"autosave"` writes a fresh capture on a debounce after any world change; `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
15
+ /** `"autosave"` writes a fresh capture on a trailing timer while the world changes (at most once per `autosaveMs`, so a never-idle world still persists); `"manual"` writes only on an explicit `save()`/`checkpoint()` — the save-point / quest-trigger model. */
16
16
  export type RuntimeSaveMode = "autosave" | "manual";
17
17
  /** How {@link createRuntimeSave} is wired — the live world `target`, the `backend` it persists through, and optional mode/slot/versioning/cadence knobs. */
18
18
  export interface RuntimeSaveConfig {
@@ -29,7 +29,7 @@ export interface RuntimeSaveConfig {
29
29
  /** Save-format version; bump + pass {@link migrate} on a breaking world-shape change. */
30
30
  version?: number;
31
31
  migrate?: (data: unknown, fromVersion: number) => WorldSnapshot;
32
- /** Debounce for `autosave` mode. Default `3000`. */
32
+ /** Trailing autosave interval (ms) for `autosave` mode — the world persists at most once per interval and always within one interval of its first unsaved change. Default `3000`. */
33
33
  autosaveMs?: number;
34
34
  now?: () => number;
35
35
  timers?: SaveTimers;
@@ -41,7 +41,7 @@ export type RuntimeSaveOptions = Omit<RuntimeSaveConfig, "target">;
41
41
  * Whole-world save/load bound to a live world and a pluggable backend. `save()`
42
42
  * captures `target.snapshot()` and writes it; `load()` reads it back and
43
43
  * `target.hydrate()`s the whole world. In `autosave` mode it also writes on a
44
- * debounce after any change. Named slots, versioned migration, and offline↔cloud
44
+ * trailing timer while the world keeps changing. Named slots, versioned migration, and offline↔cloud
45
45
  * (backend swap) all come for free from the underlying save store.
46
46
  */
47
47
  export interface RuntimeSave {
@@ -51,10 +51,19 @@ export function createRuntimeSave(config) {
51
51
  store.set(config.target.snapshot());
52
52
  return store.save();
53
53
  }
54
+ /**
55
+ * Arm a trailing autosave. A living world (AI, physics, the day/night clock)
56
+ * changes every frame, so a debounce that *reset* its timer on every change
57
+ * would never elapse and the game would never persist — the timer would be
58
+ * pushed forward faster than it could fire. Instead the first change since the
59
+ * last write arms a single timer and later changes ride that same timer; when
60
+ * it fires it captures the latest snapshot. The result is at most one write per
61
+ * `autosaveMs`, and always within `autosaveMs` of the first unsaved change,
62
+ * even while the world never stops moving.
63
+ */
54
64
  function scheduleAutosave() {
55
- if (disposed || restoring)
65
+ if (disposed || restoring || pendingTimer !== null)
56
66
  return;
57
- clearTimer();
58
67
  pendingTimer = timers.set(() => {
59
68
  pendingTimer = null;
60
69
  void persist();
@@ -92,11 +92,11 @@ export function classifyAssetResponse(probe) {
92
92
  });
93
93
  if (status !== undefined && (status === 0 || status >= 400)) {
94
94
  const suffix = statusText === undefined || statusText.length === 0 ? "" : ` ${statusText}`;
95
- return finish("missing", `Asset ${who} did not load: HTTP ${status}${suffix}. The file is not being served — provision the pack (e.g. \`assets pull <source>\`) or check the model path.`);
95
+ return finish("missing", `Asset ${who} did not load: HTTP ${status}${suffix}. The bytes are not being served at that path — run \`assets pull <source>\`, which provisions the pack into the dev server's served models dir (\`apps/dev/public/models\` in the jgengine monorepo) so no manual copy is needed; or check the model path.`);
96
96
  }
97
97
  const htmlContentType = contentType !== undefined && contentType.toLowerCase().includes("text/html");
98
98
  if (signature === "html" || (htmlContentType && signature !== "glb" && signature !== "gltf-json")) {
99
- return finish("html", `Asset ${who} resolved to an HTML page, not a model — the dev server returned its fallback document because the file is missing. Provision the pack (\`assets pull <source>\`) so the bytes exist under the served path.`);
99
+ return finish("html", `Asset ${who} resolved to an HTML page, not a model — the dev server returned its fallback document because the file is missing. Run \`assets pull <source>\` to provision the pack into the served models dir (\`apps/dev/public/models\` in the jgengine monorepo); the bytes must exist there, no manual copy needed.`);
100
100
  }
101
101
  if (signature === "glb" || signature === "gltf-json")
102
102
  return finish("ok", `Asset ${who} loaded (${signature}).`);
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  function metric(policy, fromId, toId, deps) {
2
3
  switch (policy) {
3
4
  case "nearest":
@@ -21,7 +22,7 @@ export function selectAutoTarget(policy, fromId, deps) {
21
22
  if (candidates.length === 0)
22
23
  return null;
23
24
  if (policy === "random") {
24
- const rng = deps.rng ?? Math.random;
25
+ const rng = resolveRng(deps.rng, "autoTarget");
25
26
  return candidates[Math.min(candidates.length - 1, Math.floor(rng() * candidates.length))];
26
27
  }
27
28
  const higher = prefersHigher(policy);
@@ -1,3 +1,4 @@
1
+ import { resolveRng } from "../random/resolveRng.js";
1
2
  function clamp01(value) {
2
3
  return Math.min(1, Math.max(0, value));
3
4
  }
@@ -6,6 +7,6 @@ export function captureChance(input) {
6
7
  const hpFactor = 1 - clamp01(input.hpFraction) * 0.6;
7
8
  return clamp01((input.catchPower * hpFactor) / Math.max(difficulty, 0.0001));
8
9
  }
9
- export function rollCapture(input, rng = Math.random) {
10
- return rng() < captureChance(input);
10
+ export function rollCapture(input, rng) {
11
+ return resolveRng(rng, "rollCapture")() < captureChance(input);
11
12
  }
@@ -22,6 +22,8 @@ export interface SceneEntity<TMeta = unknown> {
22
22
  role: EntityRole;
23
23
  movement: EntityMovement;
24
24
  behaviors: readonly BehaviorDescriptor[];
25
+ /** Render-only visibility: `true` skips the entity's visual entirely (seated vehicle riders, cutscene actors, stealth cloaks) while the entity keeps simulating — position, stats, AI targeting, and queries are untouched. Absent/`false` renders normally. */
26
+ hidden?: boolean;
25
27
  /** Game-defined per-instance data set at `spawn`/`update` and carried to `renderEntity`/queries — narrow with {@link entityMetaOf} (or a type guard) on read (#286.1); never encode state in the id/name string. */
26
28
  meta: TMeta;
27
29
  }
@@ -44,6 +46,7 @@ export interface SpawnOptions<TMeta = unknown> {
44
46
  role?: EntityRole;
45
47
  movement?: EntityMovement;
46
48
  behaviors?: readonly BehaviorDescriptor[];
49
+ hidden?: boolean;
47
50
  meta?: TMeta;
48
51
  /** When `id` is already spawned: `"throw"` (default), `"replace"` (fresh spawn over it — remount-safe world setup, #284.10), or `"keep"` (leave it untouched and return the id). */
49
52
  onExisting?: "throw" | "replace" | "keep";
@@ -68,7 +71,7 @@ export interface PoseConstraintFrame {
68
71
  }
69
72
  /** Return a replacement position to constrain the step, or nothing to accept it. */
70
73
  export type PoseConstraint = (frame: PoseConstraintFrame) => readonly [number, number, number] | undefined | void;
71
- export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>, "name" | "rotationY" | "rotationX" | "rotationZ" | "role" | "movement" | "behaviors" | "meta">> & {
74
+ export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>, "name" | "rotationY" | "rotationX" | "rotationZ" | "role" | "movement" | "behaviors" | "hidden" | "meta">> & {
72
75
  /** Accepts the same friendly shapes as `spawn`/`setPose` (#286.13). Raw patch semantics — velocity is not derived; use `setPose` with `dt` for that. */
73
76
  position?: SpawnPositionInput;
74
77
  };
@@ -91,6 +91,7 @@ export function createEntityStore() {
91
91
  role: options.role ?? "prop",
92
92
  movement: options.movement ?? {},
93
93
  behaviors: options.behaviors ?? [],
94
+ ...(options.hidden === undefined ? {} : { hidden: options.hidden }),
94
95
  meta: options.meta,
95
96
  });
96
97
  spawnPoses.set(id, { position, rotationY });
@@ -124,6 +125,8 @@ export function createEntityStore() {
124
125
  current.movement = patch.movement;
125
126
  if (patch.behaviors !== undefined)
126
127
  current.behaviors = patch.behaviors;
128
+ if (patch.hidden !== undefined)
129
+ current.hidden = patch.hidden;
127
130
  if (patch.meta !== undefined)
128
131
  current.meta = patch.meta;
129
132
  store.set(id, current);