@inventure71/paddockjs 1.0.0 → 2.0.1

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 (203) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +219 -12
  4. package/docs/architecture.md +126 -45
  5. package/docs/concepts.md +9 -5
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +317 -22
  8. package/docs/data_model.md +1 -1
  9. package/docs/index.md +10 -0
  10. package/docs/learnings.md +22 -0
  11. package/docs/rules.md +71 -14
  12. package/docs/sense_contract.md +59 -0
  13. package/docs/system_specs.md +75 -13
  14. package/docs/training.md +280 -32
  15. package/examples/python/README.md +62 -0
  16. package/examples/python/base_policy_server.py +191 -0
  17. package/examples/python/requirements.txt +2 -0
  18. package/package.json +12 -4
  19. package/src/api/PaddockSimulatorController.js +6 -33
  20. package/src/app/BrowserExpertAdapter.js +76 -9
  21. package/src/app/BrowserExpertExternalRenderer.js +303 -0
  22. package/src/app/F1SimulatorApp.js +291 -1630
  23. package/src/app/banners/bannerFormatters.js +55 -0
  24. package/src/app/banners/raceDataBannerController.js +141 -0
  25. package/src/app/banners/stewardMessageController.js +99 -0
  26. package/src/app/camera/cameraConstants.js +21 -0
  27. package/src/app/camera/cameraController.js +265 -0
  28. package/src/app/camera/cameraGeometry.js +176 -0
  29. package/src/app/domBindings.js +4 -0
  30. package/src/app/readouts/carOverviewRenderer.js +109 -0
  31. package/src/app/readouts/controlStateRenderer.js +37 -0
  32. package/src/app/readouts/raceStatusRenderer.js +133 -0
  33. package/src/app/readouts/readoutFormatters.js +58 -0
  34. package/src/app/readouts/telemetryRenderer.js +113 -0
  35. package/src/app/readouts/timingTowerRenderer.js +177 -0
  36. package/src/app/rendering/appAssets.js +38 -0
  37. package/src/app/rendering/carRenderer.js +286 -0
  38. package/src/app/rendering/displayUtils.js +22 -0
  39. package/src/app/rendering/drsTrailRenderer.js +90 -0
  40. package/src/app/rendering/expertSensorRenderer.js +126 -0
  41. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  42. package/src/app/rendering/replayGhostRenderer.js +124 -0
  43. package/src/app/rendering/trackRenderer.js +25 -0
  44. package/src/app/runtime/frameLoop.js +66 -0
  45. package/src/app/runtime/runtimeTiming.js +34 -0
  46. package/src/app/runtime/runtimeVisibility.js +45 -0
  47. package/src/config/defaultOptions.js +20 -0
  48. package/src/config/restartOptions.js +34 -0
  49. package/src/environment/actions.js +13 -5
  50. package/src/environment/controllerLoop.js +261 -0
  51. package/src/environment/episode.js +98 -2
  52. package/src/environment/evaluation.js +54 -32
  53. package/src/environment/index.d.ts +376 -10
  54. package/src/environment/index.js +1 -0
  55. package/src/environment/metrics.js +159 -0
  56. package/src/environment/observationOptions.js +22 -1
  57. package/src/environment/observationVector.js +200 -0
  58. package/src/environment/observations.js +199 -141
  59. package/src/environment/options.js +37 -5
  60. package/src/environment/recorder.js +13 -6
  61. package/src/environment/rewards.js +13 -2
  62. package/src/environment/runtime.js +272 -23
  63. package/src/environment/scenarios.js +57 -9
  64. package/src/environment/sensors/bodySenses.js +23 -0
  65. package/src/environment/sensors/boundarySenses.js +17 -0
  66. package/src/environment/sensors/carRays.js +75 -0
  67. package/src/environment/sensors/contactSenses.js +32 -0
  68. package/src/environment/sensors/index.js +157 -0
  69. package/src/environment/sensors/indexedRayBands.js +103 -0
  70. package/src/environment/sensors/nearbyCars.js +52 -0
  71. package/src/environment/sensors/opponentRadar.js +75 -0
  72. package/src/environment/sensors/rayConfig.js +110 -0
  73. package/src/environment/sensors/rayDefaults.js +6 -0
  74. package/src/environment/sensors/rayGeometry.js +60 -0
  75. package/src/environment/sensors/rayGuards.js +19 -0
  76. package/src/environment/sensors/sensorTargets.js +66 -0
  77. package/src/environment/sensors/surfaceRays.js +360 -0
  78. package/src/environment/sensors/trackRays.js +424 -0
  79. package/src/environment/sensors.js +1 -367
  80. package/src/environment/specs.js +159 -26
  81. package/src/environment/workerProtocol.js +13 -1
  82. package/src/index.d.ts +171 -6
  83. package/src/index.js +6 -32
  84. package/src/rendering/proceduralTrackAsset.js +20 -823
  85. package/src/rendering/renderSnapshot.js +38 -12
  86. package/src/rendering/track/finishLineRenderer.js +34 -0
  87. package/src/rendering/track/gridRenderer.js +47 -0
  88. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  89. package/src/rendering/track/pitLaneRenderer.js +248 -0
  90. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  91. package/src/rendering/track/trackRenderConstants.js +38 -0
  92. package/src/rendering/track/trackRenderGeometry.js +94 -0
  93. package/src/simulation/collisionGeometry.js +1 -1
  94. package/src/simulation/driver/driverControlConstants.js +28 -0
  95. package/src/simulation/driver/driverController.js +47 -0
  96. package/src/simulation/driver/driverInput.js +28 -0
  97. package/src/simulation/driver/driverMath.js +6 -0
  98. package/src/simulation/driver/driverPersonality.js +25 -0
  99. package/src/simulation/driver/edgeRecovery.js +48 -0
  100. package/src/simulation/driver/racingControls.js +399 -0
  101. package/src/simulation/driver/racingLinePlan.js +166 -0
  102. package/src/simulation/driver/recoveryDynamics.js +38 -0
  103. package/src/simulation/driver/rejoinControls.js +213 -0
  104. package/src/simulation/driver/safetyCarControls.js +35 -0
  105. package/src/simulation/driver/trafficScan.js +51 -0
  106. package/src/simulation/driverController.js +4 -619
  107. package/src/simulation/participants/participantInteractions.js +120 -0
  108. package/src/simulation/pit/pitFlow.js +239 -0
  109. package/src/simulation/pit/pitIntent.js +92 -0
  110. package/src/simulation/pit/pitKinematics.js +24 -0
  111. package/src/simulation/pit/pitOccupancy.js +47 -0
  112. package/src/simulation/pit/pitPenaltyService.js +71 -0
  113. package/src/simulation/pit/pitQueue.js +60 -0
  114. package/src/simulation/pit/pitRouteMovement.js +167 -0
  115. package/src/simulation/pit/pitRouting.js +319 -0
  116. package/src/simulation/pit/pitService.js +43 -0
  117. package/src/simulation/pit/pitServiceConstants.js +21 -0
  118. package/src/simulation/pit/pitServiceProfile.js +45 -0
  119. package/src/simulation/pit/pitSnapshots.js +61 -0
  120. package/src/simulation/pit/pitState.js +113 -0
  121. package/src/simulation/pit/pitTireService.js +94 -0
  122. package/src/simulation/race/classification.js +104 -0
  123. package/src/simulation/race/gridPenalties.js +37 -0
  124. package/src/simulation/race/raceControlState.js +38 -0
  125. package/src/simulation/race/raceDistance.js +32 -0
  126. package/src/simulation/race/raceFinish.js +65 -0
  127. package/src/simulation/race/raceLifecycle.js +67 -0
  128. package/src/simulation/race/raceOrder.js +113 -0
  129. package/src/simulation/race/raceProgress.js +129 -0
  130. package/src/simulation/race/raceSetup.js +93 -0
  131. package/src/simulation/race/raceStep.js +80 -0
  132. package/src/simulation/race/redFlag.js +15 -0
  133. package/src/simulation/race/retirements.js +44 -0
  134. package/src/simulation/race/safetyCar.js +92 -0
  135. package/src/simulation/race/stalledDnf.js +77 -0
  136. package/src/simulation/raceSimulation.js +231 -2817
  137. package/src/simulation/replay/replayGhosts.js +128 -0
  138. package/src/simulation/rules/moduleConfig.js +72 -0
  139. package/src/simulation/rules/penaltyConfig.js +99 -0
  140. package/src/simulation/rules/penaltyEvents.js +18 -0
  141. package/src/simulation/rules/penaltyStats.js +26 -0
  142. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  143. package/src/simulation/rules/ruleDefaults.js +85 -0
  144. package/src/simulation/rules/rulesReview.js +144 -0
  145. package/src/simulation/rules/rulesetPresets.js +47 -0
  146. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  147. package/src/simulation/rulesConfig.js +5 -323
  148. package/src/simulation/snapshots/raceSnapshots.js +193 -0
  149. package/src/simulation/timing/drsTiming.js +87 -0
  150. package/src/simulation/timing/gapEstimation.js +38 -0
  151. package/src/simulation/timing/lapTelemetry.js +182 -0
  152. package/src/simulation/timing/raceTiming.js +15 -0
  153. package/src/simulation/timing/sectorPerformance.js +60 -0
  154. package/src/simulation/timing/timingConstants.js +5 -0
  155. package/src/simulation/timing/timingHistory.js +46 -0
  156. package/src/simulation/timing/timingLines.js +79 -0
  157. package/src/simulation/track/centerlineSpline.js +59 -0
  158. package/src/simulation/track/drsZones.js +67 -0
  159. package/src/simulation/track/pitLaneAccess.js +103 -0
  160. package/src/simulation/track/pitLaneLayout.js +395 -0
  161. package/src/simulation/track/pitLaneState.js +160 -0
  162. package/src/simulation/track/proceduralCenterline.js +237 -0
  163. package/src/simulation/track/sampleGeometry.js +49 -0
  164. package/src/simulation/track/spatialQueries.js +154 -0
  165. package/src/simulation/track/startStraight.js +141 -0
  166. package/src/simulation/track/trackConstants.js +109 -0
  167. package/src/simulation/track/trackGenerationOptions.js +283 -0
  168. package/src/simulation/track/trackMath.js +241 -0
  169. package/src/simulation/track/trackModel.js +240 -0
  170. package/src/simulation/track/trackQueryGrid.js +94 -0
  171. package/src/simulation/track/trackQueryIndex.js +659 -0
  172. package/src/simulation/track/trackQueryPitIndex.js +140 -0
  173. package/src/simulation/track/trackQueryScratch.js +46 -0
  174. package/src/simulation/track/trackQueryStats.js +28 -0
  175. package/src/simulation/track/trackSectors.js +19 -0
  176. package/src/simulation/track/trackStatePolicy.js +24 -0
  177. package/src/simulation/track/trackValidation.js +98 -0
  178. package/src/simulation/trackModel.js +1 -1706
  179. package/src/simulation/vehicle/contactResolution.js +181 -0
  180. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  181. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  182. package/src/simulation/vehicle/runoffResponse.js +87 -0
  183. package/src/simulation/vehicle/surfacePriority.js +28 -0
  184. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  185. package/src/simulation/vehicle/vehicleKinematics.js +37 -0
  186. package/src/simulation/vehicle/vehiclePhysics.js +364 -0
  187. package/src/simulation/vehicle/vehicleSnapshots.js +327 -0
  188. package/src/simulation/vehicle/vehicleState.js +220 -0
  189. package/src/simulation/vehicle/wheelSurface.js +152 -0
  190. package/src/simulation/vehicleGeometry.js +1 -247
  191. package/src/simulation/vehiclePhysics.js +1 -136
  192. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  193. package/src/simulation/wheelSurface.js +1 -338
  194. package/src/styles.css +42 -0
  195. package/src/ui/cameraControlsTemplate.js +20 -0
  196. package/src/ui/carOverviewTemplate.js +33 -0
  197. package/src/ui/componentTemplates.js +14 -360
  198. package/src/ui/raceCanvasTemplate.js +69 -0
  199. package/src/ui/raceControlsTemplate.js +36 -0
  200. package/src/ui/shellTemplate.js +5 -4
  201. package/src/ui/telemetryDrawerTemplate.js +55 -0
  202. package/src/ui/telemetryTemplates.js +136 -0
  203. package/src/ui/timingTowerTemplate.js +34 -0
@@ -14,8 +14,14 @@ mountF1Simulator(root, {
14
14
  onDriverOpen,
15
15
  seed,
16
16
  trackSeed,
17
+ trackGeneration,
18
+ trackQueryIndex,
19
+ warmup,
17
20
  totalLaps,
21
+ physicsMode,
18
22
  rules,
23
+ participantInteractions,
24
+ replayGhosts,
19
25
  initialCameraMode,
20
26
  theme,
21
27
  title,
@@ -24,6 +30,7 @@ mountF1Simulator(root, {
24
30
  backLinkLabel,
25
31
  showBackLink,
26
32
  ui,
33
+ debug,
27
34
  assets,
28
35
  expert,
29
36
  onLoadingChange,
@@ -45,12 +52,19 @@ const simulator = createPaddockSimulator({
45
52
  onDriverOpen,
46
53
  seed,
47
54
  trackSeed,
55
+ trackGeneration,
56
+ trackQueryIndex,
57
+ warmup,
48
58
  totalLaps,
59
+ physicsMode,
49
60
  rules,
61
+ participantInteractions,
62
+ replayGhosts,
50
63
  initialCameraMode,
51
64
  preset,
52
65
  theme,
53
66
  ui,
67
+ debug,
54
68
  assets,
55
69
  expert,
56
70
  });
@@ -104,6 +118,7 @@ const env = createPaddockEnvironment({
104
118
  standingStart: false,
105
119
  modules: {
106
120
  penalties: {
121
+ enabled: true,
107
122
  trackLimits: { strictness: 0.25 },
108
123
  },
109
124
  },
@@ -121,6 +136,9 @@ result = env.step({
121
136
  ```
122
137
 
123
138
  `controlledDrivers` is required. It supports one or many externally controlled cars. Non-controlled participants use the built-in driver AI in the stable 1.0 environment API.
139
+ `externalRenderer` is optional and observer-only. It can be a function or `{ onFrame(frame) }`, and receives `{ snapshot, observation, meta }` on `reset`, `step`, and `resetDrivers`. The hook does not mutate simulation state and hook failures are isolated so stepping continues.
140
+ `warmup` is optional and enabled by default. It primes a disposable runtime during load/reset creation and caches by configuration fingerprint so repeated identical resets skip warmup. Use `warmup: { enabled, policy: 'config-change' | 'always' | 'never', steps }` or `warmup: false` to disable.
141
+ `physicsMode` is optional and accepts `'arcade'` or `'simulator'`. Invalid values fall back to `'arcade'`, which is the default compatibility mode. `'simulator'` keeps the same public action contract but uses stricter 2D velocity/yaw dynamics and exposes additional telemetry fields on snapshots and observations: `velocityX`, `velocityY`, `lateralG`, `longitudinalG`, `gripUsage`, `slipAngleRadians`, `tractionLimited`, and `stabilityState`. In simulator mode, `slipAngleRadians` is derived from the car heading versus actual velocity direction, and mixed wheel surfaces are averaged for physics while wheel snapshots still report each contact patch. Terminal and race-control-held simulator cars publish `velocityX: 0` and `velocityY: 0` whenever scalar `speed` is zero, so training loops do not see stale pre-DNF, pre-grid, pre-red-flag, or pre-queue motion. Arcade snapshots publish `velocityX` and `velocityY` as `null`; consumers that need a heading-aligned arcade velocity should derive it from `heading` and `speed`.
124
142
  `rules` is an optional override object for the race rules documented in [rules.md](rules.md). Flat keys such as `standingStart: false` still work for existing behavior. Advanced systems live under `rules.modules` so hosts can choose a preset and then override individual modules:
125
143
 
126
144
  ```js
@@ -135,6 +153,18 @@ rules: {
135
153
  tirePitRequestThresholdPercent: 50,
136
154
  tirePitCommitThresholdPercent: 30,
137
155
  },
156
+ tireStrategy: {
157
+ enabled: true,
158
+ mandatoryDistinctDryCompounds: 2,
159
+ },
160
+ tireDegradation: {
161
+ enabled: false,
162
+ },
163
+ stalledDnf: {
164
+ enabled: true,
165
+ maxStoppedSeconds: 12,
166
+ speedThresholdKph: 5,
167
+ },
138
168
  penalties: {
139
169
  trackLimits: { strictness: 0.8 },
140
170
  collision: { strictness: 0.5, consequences: [{ type: 'time', seconds: 5 }] },
@@ -145,7 +175,7 @@ rules: {
145
175
  }
146
176
  ```
147
177
 
148
- Supported rulesets are `paddock`, `grandPrix2025`, `fia2025`, and `custom`. The `fia2025` name is a 2024-2025-era grand-prix-style package preset; explicit module config always wins over preset defaults. Penalty strictness is clamped from `0` to `1`, where `0` disables enforcement for that subsection and `1` uses the configured rule margin. `rules` is not a direct state-mutation API.
178
+ Supported rulesets are `paddock`, `grandPrix2025`, `fia2025`, and `custom`. The `fia2025` name is a 2024-2025-era grand-prix-style package preset; explicit module config always wins over preset defaults. `rules.modules.pitStops.enabled` is the switch for automatic pit routing, pit-lane status, and successful `setPitIntent()` calls. `rules.modules.tireStrategy.enabled` is separate: it controls available compounds, tire-requirement stewarding, and pit target choices, but it does not enable pit stops. Use `ruleset: 'fia2025'` / `ruleset: 'grandPrix2025'` or explicitly enable both modules when a host wants a two-compound race with automatic stops. `rules.modules.tireDegradation.enabled: false` freezes tyre energy for deterministic training or visual comparison without changing tire compounds or pit rules. `rules.modules.stalledDnf` defaults to `{ enabled: false, maxStoppedSeconds: 12, speedThresholdKph: 5 }`; set `enabled: true` to retire stuck off-track cars with `dnfReason: 'stalled-off-track'` only when they are off legal racing or pit surfaces. Pre-start, red flag, legal pit surfaces, active pit handling, and already-finished cars are excluded. Penalty strictness is clamped from `0` to `1`, where `0` disables enforcement for that subsection and `1` uses the configured rule margin. `rules` is not a direct state-mutation API.
149
179
 
150
180
  Scenario support:
151
181
 
@@ -175,7 +205,65 @@ scenario: {
175
205
  }
176
206
  ```
177
207
 
178
- Scenario placement is applied only during environment creation/reset through the simulator state API. It does not give policies a state-mutation path during `step(actions)`. Explicit `placements` override preset placement for the same driver, and `traffic` places another participant relative to a placed or existing car. Static obstacles, ghost cars, debug mutation, assisted controls, and Python Gymnasium wrappers are intentionally deferred. The supported package boundary today is JavaScript Gym-style control plus a JSON worker protocol, not a Python Gym package and not a scenario editor.
208
+ Scenario placement is applied only during environment creation/reset through the simulator state API. It does not give policies a state-mutation path during `step(actions)`. Explicit `placements` override preset placement for the same driver, and `traffic` places another participant relative to a placed or existing car. `env.reset(partialOptions)` preserves omitted nested option groups, but an explicitly supplied `scenario.placements` object replaces the old placement map so callers can clear stale reset placements with `{ scenario: { placements: {} } }`. Static obstacles, debug mutation, assisted controls, and Python Gymnasium wrappers are intentionally deferred. Replay ghosts and participant interaction profiles are supported separately below. The supported package boundary today is JavaScript Gym-style control plus a JSON worker protocol, not a Python Gym package and not a scenario editor.
209
+
210
+ `env.resetDrivers(placements)` applies the same placement shape to selected controlled drivers without recreating the whole environment. It is an episode-boundary API for batched training: selected drivers get a new `episodeId`, `episodeStep: 0`, cleared manual controls, and cleared pit intent. Non-selected controlled drivers keep their current car state and episode counters. Max-step truncation is evaluated from each reported driver's own `episodeStep`, so resetting one truncated driver clears that driver's done state without rewinding the environment `info.step` or changing other drivers' episode state. If all reported controlled drivers are ended, top-level `done` is true even when their terminal states are mixed, such as one destroyed driver and one max-step-truncated driver. Reset placement is still validated against the normal track/runoff/barrier model before observations are returned. If a reset placement puts a car into the barrier destruction boundary, the returned result reports that driver as `destroyed` with `endReason: 'destroyed'` and stable miss-valued rays instead of doing an expensive far-out sensor scan. With reset-only observation scope, top-level terminal state is evaluated over the reported reset drivers but `info.drivers` still reflects each controlled driver's own state, so a destroyed reset driver does not falsely terminate healthy non-reset drivers. This API is not accepted inside `step(actions)`.
211
+
212
+ Participant interaction profiles are simulator-owned environment setup, not RL logic. They let hosts run multiple real cars in one environment without forcing every car to collide with or appear in every other car's sensors:
213
+
214
+ ```js
215
+ participantInteractions: {
216
+ defaultProfile: 'normal',
217
+ drivers: {
218
+ 'model-a': { profile: 'normal' },
219
+ 'model-b': { profile: 'isolated-training' },
220
+ },
221
+ }
222
+ ```
223
+
224
+ Supported profiles are:
225
+
226
+ - `normal`: collidable, ray-detectable, nearby-detectable, pit-lane-blocking, and race-order-affecting.
227
+ - `isolated-training`: non-colliding, hidden from rays and nearby-car observations, non-blocking in pit-lane occupancy, but still included in race order.
228
+ - `batch-training`: non-colliding, hidden from rays and nearby-car observations, non-blocking in pit-lane occupancy, and excluded from race order/classification while still being a rendered physics-driven car in `snapshot.cars`.
229
+ - `phantom-race`: non-colliding and non-blocking in pit-lane occupancy, but visible to rays and nearby-car observations and included in race order.
230
+ - `time-trial-overlay`: non-colliding, sensor-hidden, non-blocking, and excluded from race order/classification while still being a physics-driven car in `snapshot.cars`.
231
+
232
+ Every profile flag can be overridden per driver. This does not add teleport, assisted steering, or trainer behavior: cars still advance through the normal vehicle physics and public action/state APIs.
233
+
234
+ For multi-car learner batches, `batch-training` is the default no-collision profile. It hides each learner from other cars' ray sensors and `nearbyCars` observations, excludes learner cars from race order, and still keeps them visible as normal physics cars in snapshots/rendering. Use `isolated-training` when a non-colliding car should still affect order. If a non-colliding car should remain sensor-visible, use `phantom-race` or explicitly override `detectableByRays` / `detectableAsNearby`.
235
+
236
+ Browser render snapshots include each car's resolved `interaction` object. The default browser renderer uses that to draw a blue outline marker around non-colliding physics participants while keeping them solid and clickable. That visual marker does not change collision, sensor, timing, pit, or control behavior.
237
+
238
+ Replay ghosts are separate trajectory-driven reference entities:
239
+
240
+ ```js
241
+ replayGhosts: [
242
+ {
243
+ id: 'best-lap',
244
+ label: 'Best Lap',
245
+ color: '#00ff84',
246
+ opacity: 0.35,
247
+ visible: true,
248
+ trajectory: [
249
+ {
250
+ timeSeconds: 0,
251
+ x: 100,
252
+ y: 200,
253
+ headingRadians: 1.2,
254
+ speedKph: 180,
255
+ progressMeters: 0,
256
+ },
257
+ ],
258
+ sensors: {
259
+ detectableByRays: false,
260
+ detectableAsNearby: false,
261
+ },
262
+ },
263
+ ]
264
+ ```
265
+
266
+ Replay ghosts appear in `snapshot.replayGhosts`, interpolate by `timeSeconds`, render as translucent browser overlays, and never enter `snapshot.cars`, timing rows, race order, pit logic, collision resolution, or steward penalties. They are hidden from rays and nearby-car observations by default. If `sensors.detectableByRays` or `sensors.detectableAsNearby` is explicitly enabled, observations may report that replay ghost as a sensor target with `targetType` / `entityType: 'replayGhost'`; it still remains a non-physics reference entity. Sensor target filtering and enrichment treat `{ entityType, id }` as the target identity, so a replay ghost id collision cannot replace a real car's nearby-radar velocity facts and an opt-in same-id reference ghost remains visible to that car's sensors.
179
267
 
180
268
  Actions use normalized low-level controls:
181
269
 
@@ -189,13 +277,69 @@ Actions use normalized low-level controls:
189
277
  }
190
278
  ```
191
279
 
192
- `steering` maps to the simulator's internal steering limit. `throttle` and `brake` are clamped from `0` to `1`.
280
+ Browser expert mode also exposes an external render hook for authoritative node-frame visualization:
281
+
282
+ ```js
283
+ simulator.expert.attachExternalRenderer(source);
284
+ simulator.expert.detachExternalRenderer();
285
+ const state = simulator.expert.getExternalRendererState();
286
+ ```
287
+
288
+ `source` contract:
289
+
290
+ ```js
291
+ {
292
+ subscribe(onFrame) {
293
+ // call onFrame({ snapshot, observation, meta? })
294
+ return () => { /* unsubscribe */ };
295
+ },
296
+ }
297
+ ```
298
+
299
+ While attached, expert runtime is strict render-only and rejects local `step()`, `resetDrivers()`, and `reset()`. Detaching restores the current local simulation track surface before hosts resume local expert stepping, even if the last external frame used a different track. Package core stays transport-agnostic: WebSocket connection/discovery belongs to host integration code, not to `src/`.
300
+
301
+ Operationally this is one architecture with two modes:
302
+
303
+ - local expert mode: browser owns stepping
304
+ - external render mode: browser only renders authoritative external frames
305
+
306
+ Policy Runner maps this directly through supported controller modes: `Distilled policy` and `Policy server` for browser-owned stepping, and `Live preview stream` for external render-only frames.
307
+
308
+ `steering` is an absolute steering-wheel target: `-1` points at the maximum left steering limit, `0` points at center, `1` points at the maximum right steering limit, and intermediate values are percentages of that limit. The vehicle physics moves the steering angle toward that target through the configured steering-rate limit, so centering is physical rather than an instantaneous snap. `throttle` and `brake` are clamped from `0` to `1`. `steering`, `throttle`, and `brake` are required on every controlled-driver action; missing or non-finite values fail action validation instead of defaulting to zero.
193
309
 
194
310
  Actions may also include `pitIntent`. `0` clears a pending pit request and is accepted as a no-op even when pit stops are disabled, so fixed-shape policies can always send the full action object. `1` keeps trying until the next free-enough pit-entry window, and `2` commits to entering at the next pit-entry window even when pit-lane capacity or gap checks would block an opportunistic stop. Expert-controlled drivers start with `pitIntent: 0`, and tire-threshold automatic pit calls are disabled for those drivers so models do not manually steer into pit-lane geometry or get surprise pit calls from the built-in strategy. If pit stops are disabled, if the car has no pit assignment, or if the car is already entering, queued, servicing, or exiting, the environment rejects non-zero pit requests through the configured `actionPolicy`.
195
311
 
196
312
  Pit actions may also include `pitCompound` or `pitTargetCompound`, for example `{ pitIntent: 2, pitCompound: 'H' }`. The value must be one of `rules.modules.tireStrategy.compounds`. If omitted, the simulator keeps the existing pending target or picks the first configured compound different from the current tire. The model never steers down pit-lane geometry directly; once a request is accepted, the simulator owns pit entry, queueing, penalty hold, tire service, and pit exit.
197
313
 
198
- The recommended policy convention is `policy.predict(driverObservation) -> { steering, throttle, brake }`. This is a convention, not a base class. Users can wrap any model or algorithm behind that shape.
314
+ The recommended runtime convention is a user-owned driver controller with `decideBatch(context) -> { [driverId]: { steering, throttle, brake, pitIntent? } }`. Controllers may load any model format or call any inference backend. PaddockJS only provides the simulator runtime context, stable controlled-driver ordering, cached specs, compact observations, and normalized action validation.
315
+
316
+ ```js
317
+ import { createPaddockDriverControllerLoop } from '@inventure71/paddockjs';
318
+
319
+ const controller = {
320
+ async init(ctx) {
321
+ this.model = await loadUserModel(ctx.observationSpec);
322
+ },
323
+ async reset(ctx) {
324
+ this.hidden = resetHiddenState(ctx.controlledDrivers.length);
325
+ },
326
+ async decideBatch(ctx) {
327
+ const vectors = ctx.orderedObservations.map((item) => item.vector);
328
+ return this.model.predictBatch(vectors, this.hidden);
329
+ },
330
+ onStep(ctx) {
331
+ recordUserOwnedStats(ctx.metrics, ctx.actions);
332
+ },
333
+ };
334
+
335
+ const loop = createPaddockDriverControllerLoop({
336
+ runtime: env,
337
+ controller,
338
+ actionRepeat: 4,
339
+ });
340
+ ```
341
+
342
+ The older one-car `policy.predict(driverObservation)` style remains easy to wrap inside a controller, but new browser playback and training-style loops should use `decideBatch()` so one inference call can cover every controlled car.
199
343
 
200
344
  The environment exposes specs for external training code:
201
345
 
@@ -204,7 +348,53 @@ const actionSpec = env.getActionSpec();
204
348
  const observationSpec = env.getObservationSpec();
205
349
  ```
206
350
 
207
- `actionSpec` describes controlled drivers, normalized action ranges, and the optional pit intent values. `observationSpec` describes object observation fields, ray layout, nearby-car limits, track lookahead fields, and the versioned vector schema. `observation.lookaheadMeters` is sanitized to a finite numeric array; invalid or empty values fall back to the default `[20, 50, 100, 150]`.
351
+ `actionSpec` describes controlled drivers, normalized action ranges, and the optional pit intent values. `observationSpec` describes object observation fields, ray layout, nearby-car limits, track lookahead fields, and the versioned vector schema. `observation.lookaheadMeters` is sanitized to a finite numeric array; invalid or empty values fall back to the default `[20, 50, 100, 150]`. The opt-in `observation.profile: 'physical-driver'` profile defaults lookahead to `[]` so policies can use local driver-like senses without receiving privileged future track curvature. Realistic training runs should pair this profile with `physicsMode: 'simulator'`; that keeps the model's observed yaw, grip, contact-patch, kerb, and runoff behavior aligned with the physics it is learning to control.
352
+
353
+ Observation output can be compacted for training throughput:
354
+
355
+ ```js
356
+ observation: {
357
+ profile: 'physical-driver',
358
+ output: 'vector', // 'full' | 'vector' | 'object'
359
+ includeSchema: false,
360
+ vectorType: 'float32', // 'array' | 'float32'
361
+ }
362
+ ```
363
+
364
+ The default remains `output: 'full'`, `includeSchema: true`, and `vectorType: 'array'`, which returns `{ object, vector, schema, events }` for backward compatibility. `output: 'vector'` returns `{ vector, events }` unless schema inclusion is requested. `output: 'object'` returns `{ object, events }` unless schema inclusion is requested. `getObservationSpec()` remains the canonical schema source for compact loops. When `sensorsByDriver` changes ray or nearby-car shape for individual controlled drivers, `observationSpec.perDriver[driverId]` exposes that driver's exact ray, nearby-car, and vector schema shape. Full observations include the actual object ray geometry and Policy Runner object-backed encoding uses that geometry instead of reconstructing angles or lengths from a default layout. `vectorType: 'float32'` returns a `Float32Array` for JavaScript consumers that want typed numeric buffers; JSON worker users should keep the default array output unless their bridge explicitly handles typed arrays. Destroyed or out-of-race cars keep the same ray/vector schema and return miss-valued ray channels so model code does not need a separate terminal tensor shape.
365
+
366
+ The executable model-sense contract lives in `src/__tests__/environmentSenseContract.test.js` and is summarized in [Model Sense Contract](sense_contract.md). It treats the object observation as a policy-facing contract, checks it against simulator snapshot facts through independent unit and geometry formulas, then decodes each vector schema entry back to the object contract. A new vector field is not considered contract-covered until it has an oracle there.
367
+
368
+ Result state output can also be compacted:
369
+
370
+ ```js
371
+ result: {
372
+ stateOutput: 'none', // 'full' | 'minimal' | 'none'
373
+ resetDriversObservationScope: 'reset', // 'all' | 'reset'
374
+ }
375
+ ```
376
+
377
+ The default `stateOutput: 'full'` preserves the public `{ state: { snapshot } }` payload. `minimal` returns the lean observation snapshot used by the environment. `none` returns `state: null`, so external loops should rely on `observation`, `metrics`, and `info` instead. `resetDriversObservationScope: 'reset'` makes `resetDrivers()` return observations, metrics, and `info.controlledDrivers` only for reset drivers by default; callers can override per call with `env.resetDrivers(placements, { observationScope: 'all' | 'reset', stateOutput })`. `info.drivers` still carries episode state for the configured controlled drivers so active, non-reset drivers keep their episode ids and terminal flags. `createPaddockDriverControllerLoop()` maintains an observation cache across partial resets and merges reset-driver observations into it before the next controller decision. Partial `env.reset(options)` calls merge plain nested option groups such as `rules.modules`, `trackGeneration`, `sensors`, `observation`, `result`, and `episode`; arrays and explicit placement maps replace the old value.
378
+
379
+ The JSON worker protocol exposes the same reset-result controls through `resultOptions`:
380
+
381
+ ```js
382
+ protocol.handle({
383
+ id: 'reset-agent-3',
384
+ type: 'resetDrivers',
385
+ placements: {
386
+ 'agent-3': { distanceMeters: 1200, offsetMeters: 0, speedKph: 80 },
387
+ },
388
+ resultOptions: {
389
+ observationScope: 'reset',
390
+ stateOutput: 'none',
391
+ },
392
+ });
393
+ ```
394
+
395
+ `getState` accepts `stateOptions: { output: 'full' | 'minimal' | 'none' }`. For compatibility with reset-style messages, `resultOptions.stateOutput` is also mapped to the same state output selector on `getState` messages.
396
+
397
+ This is still an environment-control message, not an action. A policy cannot submit `resetDrivers` inside `step(actions)`.
208
398
 
209
399
  `createProgressReward()` is non-canonical demo reward code for examples and quick smoke tests, not the official reward. It returns a callback compatible with `reward(context)` and combines:
210
400
 
@@ -229,15 +419,18 @@ const reward = createProgressReward({
229
419
  });
230
420
  ```
231
421
 
232
- Custom reward functions receive the same context:
422
+ Custom reward functions receive the same context plus neutral environment facts:
233
423
 
234
424
  ```js
235
- reward({ driverId, previous, current, action, events, state }) {
236
- return current.object.self.speedKph / 100;
425
+ reward({ driverId, previous, current, action, events, state, metrics, episode }) {
426
+ if (metrics.destroyed) return -200;
427
+ if (metrics.offTrack) return -12;
428
+ if (episode.terminated) return 0;
429
+ return metrics.legalProgressDeltaMeters;
237
430
  }
238
431
  ```
239
432
 
240
- If no reward callback is provided, `result.reward` is `null`. PaddockJS does not infer, select, or tune rewards.
433
+ `metrics` is the same neutral per-driver metrics object returned in `result.metrics[driverId]`, and `episode` is the same per-driver runtime state returned in `result.info.drivers[driverId]`. These are package-owned facts, not reward policy. Reward callbacks run only for `step(actions)` transition results; reset/read paths such as `reset()`, `resetDrivers()`, and cold `getObservation()` are reward-neutral and return no transition reward. If no reward callback is provided, `result.reward` is `null`. If a callback returns `undefined`, `null`, `NaN`, or an infinite value, that driver's reward is normalized to `0`. The starter `createProgressReward()` reads speed/legal-surface facts from the reward snapshot and metrics when object observations are omitted, so the same transition keeps the same demo reward in compact vector-only mode. PaddockJS does not infer, select, or tune rewards.
241
434
 
242
435
  Neutral rollout recording is available for external training loops:
243
436
 
@@ -251,7 +444,7 @@ const next = env.step(action);
251
444
  recorder.recordStep(previous, action, next);
252
445
  ```
253
446
 
254
- Each recorded transition has `{ observation, action, reward, nextObservation, terminated, truncated, info }`. This is data export only; it does not train or update a model.
447
+ Each recorded transition has `{ observation, action, reward, nextObservation, terminated, truncated, info }`. Recorded transitions are cloned at record time, including numeric arrays and typed vectors, so later mutation of a policy action object or observation buffer cannot change already-recorded samples. This is data export only; it does not train or update a model.
255
448
 
256
449
  Deterministic evaluation helpers run fixed seeds/scenarios and report environment quality metrics:
257
450
 
@@ -266,7 +459,7 @@ const report = runEnvironmentEvaluation({
266
459
  });
267
460
  ```
268
461
 
269
- Evaluation reports include distance, lap progress, off-track step count, contact count, recovery success, pass count, and first lap time when available. These metrics are not rewards.
462
+ Evaluation reports include distance, lap progress, off-track step count, contact count, recovery success, pass count, and first lap time when available. They follow the same wheel-level legal-surface and contact-event definitions as environment metrics. `runEnvironmentEvaluation()` can accept compact base options with `result.stateOutput: 'none'`; it internally requests the minimal snapshot needed for evaluation bookkeeping. Direct `createEvaluationTracker(result)` usage requires `state.snapshot`, so use `stateOutput: 'minimal'` or `full` when wiring the tracker manually. These metrics are not rewards.
270
463
 
271
464
  `step(actions)` returns a JavaScript object designed for environment loops:
272
465
 
@@ -300,6 +493,16 @@ Evaluation reports include distance, lap progress, off-track step count, contact
300
493
  pitStopPenaltyServiceRemainingSeconds,
301
494
  pitStopsCompleted,
302
495
  },
496
+ trackRelation: {
497
+ lateralOffsetMeters,
498
+ headingErrorRadians,
499
+ legalWidthMeters,
500
+ leftBoundaryMeters,
501
+ rightBoundaryMeters,
502
+ onLegalSurface,
503
+ surface,
504
+ },
505
+ contactPatches,
303
506
  race: {
304
507
  position,
305
508
  totalCars,
@@ -316,6 +519,23 @@ Evaluation reports include distance, lap progress, off-track step count, contact
316
519
  },
317
520
  },
318
521
  reward,
522
+ metrics: {
523
+ budget: {
524
+ progressDeltaMeters,
525
+ legalProgressDeltaMeters,
526
+ offTrack,
527
+ kerb,
528
+ fullyOutsideWhiteLine,
529
+ severeCut,
530
+ destroyed,
531
+ destroyReason,
532
+ under30kph,
533
+ spinOrBackwards,
534
+ completedLap,
535
+ lapTimeSeconds,
536
+ contactCount,
537
+ },
538
+ },
319
539
  terminated,
320
540
  truncated,
321
541
  done,
@@ -331,11 +551,29 @@ Evaluation reports include distance, lap progress, off-track step count, contact
331
551
  controlledDrivers,
332
552
  actionErrors,
333
553
  endReason,
554
+ drivers: {
555
+ budget: {
556
+ terminated,
557
+ truncated,
558
+ endReason,
559
+ episodeStep,
560
+ episodeId,
561
+ },
562
+ },
334
563
  },
335
564
  }
336
565
  ```
337
566
 
338
- Observation objects use physical units such as kph, meters/second, meters, and radians. Optional `vector` values use fixed documented scaling from `schema`; they do not use hidden per-car normalization. Full simulator truth remains available under `state.snapshot`. Internally, the environment avoids rebuilding full snapshots during each `frameSkip` substep, but the returned `state.snapshot`, reward callback `previous` snapshot, and reward callback `state.snapshot` keep the same public shape.
567
+ `state.snapshot.cars` contains physics-driven participants only. Each car includes `interaction` with the resolved profile flags. `state.snapshot.replayGhosts` contains trajectory-driven replay/reference entities only. This invariant is shared by browser and headless snapshots:
568
+
569
+ ```txt
570
+ snapshot.cars = physics-driven race participants
571
+ snapshot.replayGhosts = trajectory-driven replay/reference entities
572
+ ```
573
+
574
+ Observation objects use physical units such as kph, meters/second, meters, and radians. Optional `vector` values use fixed documented scaling from `schema`; they do not use hidden per-car normalization. Full simulator truth remains available under `state.snapshot`. Internally, the environment avoids rebuilding full snapshots during each `frameSkip` substep and defaults to the non-public track query index unless `trackQueryIndex: false` is set, but the returned `state.snapshot`, reward callback `previous` snapshot, and reward callback `state.snapshot` keep the same public shape.
575
+
576
+ The environment observation now exposes local physical driver senses separately from full snapshot truth. `self.yawRateRadiansPerSecond` is the car's current yaw rate. `self.appliedControls` reports the normalized controls that actually drove the latest physics step: `steering` in `-1..1`, `steeringRadians` in simulator radians, `throttle` in `0..1`, and `brake` in `0..1`. For controlled cars this mirrors the accepted environment action; in `actionPolicy: 'report'` mode, a missing or invalid vehicle action releases stale manual controls, lets the built-in AI drive that step, and exposes its exact controls for auditing or clean local imitation datasets. `trackRelation` gives immediate local road relationship: lateral offset, heading error, legal width, left/right boundary distance, legal-surface state, and current surface. `contactPatches` exposes the four wheel/contact-patch surface readings as stable public observation data. Compact physical-driver vectors include each patch's `surfaceCode`, `signedOffsetMeters`, and `crossTrackErrorMeters` so vector-only training loops do not lose a contact-patch sense that exists in object observations.
339
577
 
340
578
  Default rays use a compact center-origin set with forward, side, and rear awareness:
341
579
 
@@ -350,11 +588,18 @@ Track distances are sampled against the actual track model. When the ray starts
350
588
  angleDegrees,
351
589
  angleRadians,
352
590
  lengthMeters,
591
+ roadEdge: {
592
+ hit,
593
+ distanceMeters,
594
+ kind, // 'exit' | 'entry' | null
595
+ },
353
596
  track: {
354
597
  hit,
355
598
  distanceMeters,
356
599
  kind, // 'exit' | 'entry' | null
357
600
  },
601
+ kerb: { hit, distanceMeters, surface },
602
+ illegalSurface: { hit, distanceMeters, surface },
358
603
  car: {
359
604
  hit,
360
605
  distanceMeters,
@@ -364,7 +609,27 @@ Track distances are sampled against the actual track model. When the ray starts
364
609
  }
365
610
  ```
366
611
 
367
- Nearby-car observations are car-relative:
612
+ `track` is kept as the compatibility alias for `roadEdge`. New ray configs may use a per-ray layout and opt into additional channels only when needed:
613
+
614
+ ```js
615
+ sensors: {
616
+ rays: {
617
+ layout: 'driver-front-heavy',
618
+ channels: ['roadEdge', 'kerb', 'illegalSurface', 'car'],
619
+ precision: 'driver', // 'driver' | 'debug'
620
+ rays: [
621
+ { id: 'front', angleDegrees: 0, lengthMeters: 260 },
622
+ { id: 'right', angleDegrees: 90, lengthMeters: 80 },
623
+ ],
624
+ },
625
+ }
626
+ ```
627
+
628
+ Ray precision defaults to `driver`. Driver precision is the active model-facing sensor contract and uses the normal sampled ray step without extra refinement. `precision: 'debug'` is available for clearly labeled diagnostics with additional edge refinement, but debug precision must not be displayed as model senses unless the policy is also running with that exact sensor config.
629
+
630
+ Surface channels are computed only when requested. `kerb` is legal racing surface. `illegalSurface` reports the first grass or gravel surface crossed by the ray. Requested surface-ray channels are part of the active object observation and compact vector schema for both default and physical-driver profiles. For normal driver interactions, `precision: 'driver'` road-edge, kerb, and illegal-surface channels keep the sampled driver contract whether the track query index is enabled or disabled. The `batch-training` participant profile keeps its indexed recovery-ray path as its own high-throughput training contract. Other indexed ray-boundary shortcuts are reserved for non-driver/debug precision paths where sub-sample diagnostics are explicitly requested. Requested surface channels preserve an origin hit at `distanceMeters: 0` even if another requested channel has no visible boundary. Ambiguous pit-connector and unusual geometry cases may use sampled fallback internally, but the returned ray object shape is unchanged. Barrier walls are not exposed as a model-facing ray target; active ray objects, vectors, schemas, and visualizations must not expose a `barrier` ray channel. Barrier contact is enforced as a shared destruction boundary in both physics modes at the rendered wall's inner face and reported through events, snapshots, metrics, and per-driver episode state.
631
+
632
+ Nearby-car observations are car-relative. When simulator velocity fields are present, `closingRateMetersPerSecond` and `timeToContactSeconds` use the actual 2D velocity vector instead of assuming the car is moving in its heading direction, so slip and recovery states remain physically accurate:
368
633
 
369
634
  ```js
370
635
  {
@@ -375,11 +640,17 @@ Nearby-car observations are car-relative:
375
640
  relativeSpeedKph,
376
641
  relativeHeadingRadians,
377
642
  ahead,
643
+ behind,
378
644
  sameLap,
645
+ closingRateMetersPerSecond,
646
+ timeToContactSeconds,
647
+ leftOverlap,
648
+ rightOverlap,
379
649
  }
380
650
  ```
381
651
 
382
- If no `reward` callback is provided, `result.reward` is `null`. If provided, rewards are returned by controlled driver ID.
652
+ If no `reward` callback is provided, `result.reward` is `null`. If provided, rewards are returned by controlled driver ID. The callback receives `driverId`, `previous`, `current`, `action`, `events`, `state.snapshot`, `metrics`, and `episode`.
653
+ Reward callbacks run only for `step(actions)` transition results. Reset and read paths return `reward: null`, so episode setup, `resetDrivers()`, and `getObservation()` cannot create fake replay rewards or trigger reward side effects.
383
654
 
384
655
  Browser expert mode is opt-in through the browser mount API:
385
656
 
@@ -405,7 +676,7 @@ simulator.expert.step({
405
676
 
406
677
  When browser expert mode is enabled, automatic ticker simulation advancement is disabled. The visual canvas advances only when host code calls `simulator.expert.reset()` or `simulator.expert.step(actions)`. Browser expert mode is a mount-time option; `restart(nextOptions)` rejects `expert` changes. Destroy and mount a new simulator to switch between built-in ticker control and expert stepping.
407
678
 
408
- `expert.visualizeSensors` is a browser-only visual debugging option. When `visualizeSensors: true` or `visualizeSensors: { rays: true }` is set, the simulator draws the controlled drivers' ray sensors in the Pixi world layer from the cars' current positions. Ray lengths and hit markers are based on the same observation data returned from the expert environment result.
679
+ `expert.visualizeSensors` is a browser-only visual debugging option. When `visualizeSensors: true` or `visualizeSensors: { rays: true }` is set, the simulator draws the selected controlled driver's ray sensors in the Pixi world layer from the car's current position. Ray lengths and hit markers are based on the same observation data returned from the expert environment result. Every detected ray channel gets its own colored marker so road-edge, kerb, illegal surface, and car hits can be debugged independently. Barrier walls are rendered as track geometry, not model-facing ray hits. In multi-driver expert runs, use `visualizeSensors: { rays: true, drivers: 'all' }` only when every controlled car's overlay is intentionally needed; hosts may also pass `drivers: ['driver-a']` to pin the overlay to a fixed controlled subset.
409
680
 
410
681
  ## Lap Telemetry Snapshot
411
682
 
@@ -442,7 +713,25 @@ Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timin
442
713
 
443
714
  `totalLaps` is optional. Values are normalized to a finite positive integer, with invalid or non-positive input falling back to a one-lap race.
444
715
 
445
- `trackSeed` is optional. If omitted, each mounted browser simulator creates a fresh procedural circuit. Passing a `trackSeed` makes the track deterministic; repeated procedural seeds are cached so multiple mounts can reuse the same generated track definition. Calling `restart({ trackSeed })` rebuilds the simulation on the deterministic circuit for that seed. Procedural tracks are generated from package-owned spline templates with deterministic jitter and validation rather than from a pure oval fallback; invalid candidates are rejected for length, world bounds, non-adjacent clearance, turn sharpness, self-intersections, and weak shape variation. The start/finish area is normalized into an explicit straight window so the starting grid, pit-entry approach, and immediate pit-exit merge area are straight even when the rest of the circuit is generated from curved controls.
716
+ `trackSeed` is optional. If omitted, each mounted browser simulator creates a fresh procedural circuit. Passing a `trackSeed` makes the track deterministic; repeated procedural seeds are cached so multiple mounts can reuse the same immutable generated track definition when the resolved generation options also match. Callers should treat `createProceduralTrack(seed, options)` results as read-only. Calling `restart({ trackSeed })` rebuilds the simulation on the deterministic circuit for that seed.
717
+
718
+ `trackGeneration` is optional and applies only when a procedural `trackSeed` is used. It accepts profile presets plus explicit overrides:
719
+
720
+ ```js
721
+ {
722
+ profile: 'training-short',
723
+ length: { minMeters: 900, maxMeters: 1800 },
724
+ startStraight: { gridMeters: 0, exitMeters: 80, blendMeters: 80 },
725
+ pitLane: { enabled: false },
726
+ shape: { scale: 0.2, cornerDensity: 1.3, variation: 0.22 },
727
+ validation: { minClearanceMultiplier: 1, maxLocalTurnRadians: 1.85 },
728
+ attempts: { primary: 80, fallback: 200 },
729
+ }
730
+ ```
731
+
732
+ Profiles are presets, not separate generators. `race` preserves the default full-length circuit with pit lane; `training-short`, `training-medium`, and `training-technical` generate smaller pitless circuits for training or demos. Resolution is `race` defaults, selected profile defaults, then explicit overrides. Procedural tracks are generated by tracing, smoothing, and warping package-owned seeded region masks, then validating the resulting centerline instead of falling back to pure ovals. Invalid candidates are rejected for length, world bounds, non-adjacent clearance, turn sharpness, self-intersections, and weak shape variation. The start/finish area is normalized into an explicit straight window so the starting grid, pit-entry approach, and immediate pit-exit merge area are straight even when the rest of the circuit is generated from curved controls.
733
+
734
+ `trackQueryIndex` controls indexed track/surface query acceleration. Browser/expert simulator mounts, headless environments, and direct race-simulation construction default this option to `true`, so visual race simulation, expert controls, Policy Runner playback, headless training/evaluation runs, and model-sense visualization use the indexed path by default. Set `trackQueryIndex: false` to force legacy non-indexed queries for comparison or debugging. It does not change public snapshot or observation shapes.
446
735
 
447
736
  `initialCameraMode` is optional and accepts `'overview'`, `'leader'`, `'selected'`, `'show-all'`, or `'pit'`. Invalid values fall back to `'leader'`. The `overview` camera frames the generated track bounds with package-owned padding and pit-lane extent. The `pit` camera frames the active track's `pitLane` geometry, zooms out when needed to keep the full pit lane inside the active race-view safe area, and falls back to `leader` when no pit lane is available. Camera zoom controls and wheel zoom apply to every mode, with zoom-out bounded by the active track frame.
448
737
 
@@ -561,7 +850,7 @@ onDriverOpen(driver) {
561
850
  }
562
851
  ```
563
852
 
564
- The callback receives the normalized driver object. If the host wants modals, routing, analytics, or external tabs, it should implement that inside this callback.
853
+ The callback receives the normalized driver object. If the host wants modals, routing, analytics, or external tabs, it should implement that inside this callback. `onDriverOpen` is host-owned navigation code and should handle its own failures; unlike lifecycle callbacks, it is not routed through `onError`.
565
854
 
566
855
  Optional lifecycle callbacks:
567
856
 
@@ -577,7 +866,7 @@ Optional lifecycle callbacks:
577
866
  }
578
867
  ```
579
868
 
580
- `onRaceEvent` receives simulation events such as `contact`, `penalty`, `track-limits`, `pit-lane-speeding`, `safety-car`, `green-flag`, `start-lights-out`, and `race-finish`. `contact` events include metadata from the production body collision solver: `firstShapeId`, `secondShapeId`, `contactType`, `depth`, and `timeOfImpact`. Host callback errors are caught; if `onError` exists, it receives `{ callback: name }` context for callback failures.
869
+ `onRaceEvent` receives simulation events such as `contact`, `penalty`, `track-limits`, `pit-lane-speeding`, `car-dnf`, `safety-car`, `green-flag`, `start-lights-out`, and `race-finish`. `car-dnf` currently reports stalled off-track retirements with `reason: 'stalled-off-track'`. `contact` events include metadata from the production body collision solver: `firstShapeId`, `secondShapeId`, `contactType`, `depth`, and `timeOfImpact`. Lifecycle callback errors are caught; if `onError` exists, it receives `{ callback: name }` context for callback failures.
581
870
 
582
871
  Race snapshots include a top-level `penalties` array. Each penalty entry includes `id`, `type`, `driverId`, `strictness`, `status`, `penaltySeconds`, `pendingPenaltySeconds`, `serviceType`, `serviceRequired`, `serviceServedAt`, `appliedAt`, `cancelledAt`, `unserved`, `positionDrop`, `gridDrop`, `disqualified`, `consequences`, `lap`, `at`, and rule-specific context such as `otherCarId`, `aheadDriverId`, `atFaultDriverId`, `sharedFault`, and `impactSpeedKph` for collision penalties or `speedKph`, `speedLimitKph`, `excessKph`, and `pitLanePart` for pit-lane speeding penalties. Clear rear contact has one at-fault driver; unclear meaningful contact records one shared-fault penalty per involved driver. Multiple time penalties for the same driver are summed into the car snapshot's `penaltySeconds` and adjusted finish/classification time.
583
872
 
@@ -646,6 +935,9 @@ ui: {
646
935
  raceDataBannerSize: 'custom',
647
936
  raceDataTelemetryDetail: false,
648
937
  timingTowerVerticalFit: 'expand-race-view',
938
+ },
939
+ debug: {
940
+ physicsModeIndicator: false,
649
941
  }
650
942
  ```
651
943
 
@@ -654,6 +946,7 @@ ui: {
654
946
  - `showFps`: controls whether the race canvas renders the FPS readout.
655
947
  - `showRaceDataPanel`: controls whether the precombined shell includes the project/radio lower-third inside the race window.
656
948
  - `showTimingTower`, `showTelemetry`: reserved component visibility flags for host layout decisions.
949
+ - `debug.physicsModeIndicator`: when `true`, renders a small top-left square in the race canvas. Blue means `physicsMode: 'arcade'`; red means `physicsMode: 'simulator'`. It defaults to `false` and is intended only for debug/development use.
657
950
  - `telemetryIncludesOverview`: controls whether the telemetry stack template embeds the car/driver overview. Composable hosts can also pass `mountTelemetryPanel(root, { includeOverview: false })`.
658
951
  - `telemetryModules`: controls optional telemetry surfaces inside stack/drawer templates. The default object enables `core` scalar readouts, `sectors` progress bars, `lapTimes`, and `sectorTimes`. It can also be `false` to disable all telemetry modules, or an array such as `['sectors', 'lapTimes']` to render only named modules. These modules are also individually mountable with `mountTelemetryCore`, `mountTelemetrySectors`, `mountTelemetryLapTimes`, and `mountTelemetrySectorTimes`.
659
952
  - `raceDataBanners.initial`: `'project'`, `'radio'`, or `'hidden'`. This controls which lower-third appears first in the precombined shell.
@@ -701,13 +994,13 @@ After every car completes `totalLaps`, `getSnapshot()` returns:
701
994
  finishedAt: 123.4,
702
995
  winner: { id, code, name, rank, finished },
703
996
  classification: [
704
- { id, code, timingCode, name, rank, lap, lapsCompleted, distanceMeters, gapMeters, gapSeconds, intervalSeconds, gapLaps, intervalLaps, finished, finishTime, penaltySeconds, adjustedFinishTime, positionDrop, disqualified },
997
+ { id, code, timingCode, name, rank, lap, lapsCompleted, distanceMeters, gapMeters, gapSeconds, intervalSeconds, gapLaps, intervalLaps, finished, finishTime, dnf, dnfReason, dnfAt, dnfOrder, penaltySeconds, adjustedFinishTime, positionDrop, disqualified },
705
998
  ],
706
999
  },
707
1000
  }
708
1001
  ```
709
1002
 
710
- Cars also include `team`, `speedKph`, `distanceMeters`, `gapAheadMeters`, `gapAheadSeconds`, `intervalAheadSeconds`, `leaderGapSeconds`, `gapAheadLaps`, `intervalAheadLaps`, `leaderGapLaps`, `finished`, `finishTime`, `finishRank`, `status`, `raceStatus`, `wavedFlag`, `penaltySeconds`, `adjustedFinishTime`, `classifiedRank`, and `lapTelemetry`. A car that has crossed the finish before the whole race is complete exposes `status: 'waved-flag'`, `raceStatus: 'waved-flag'`, and `wavedFlag: true`; the timing order keeps those cars frozen by provisional `finishRank` until final classification is available. Classification entries also expose `gapLaps`, `intervalLaps`, `positionDrop`, and `disqualified`. `gapAheadSeconds` and `intervalAheadSeconds` are the interval to the car directly ahead when both cars are on the same lead-lap cycle; `gapAheadLaps` and `intervalAheadLaps` are the whole-lap deficit to that car when it is at least one lap ahead. `leaderGapSeconds` is the same-lead-lap gap to P1. `leaderGapLaps` is the whole-lap deficit to P1. Seconds gaps are measured from the timestamp difference when both cars crossed the same hidden timing line, with fallback interpolation only when no shared timing line exists yet. The timing tower prefers lap labels such as `+1` over seconds when the relevant lap gap is positive and shows `WAVED` for a finished car before full race classification. The first car to finish sets a provisional `raceControl.winner` and receives a `car-finish` event, but the race keeps running until all cars finish. Final `raceControl.classification` sorts by `finishTime + penaltySeconds` after converting unserved drive-through and stop-go penalties, then applies position-drop and disqualification consequences.
1003
+ Cars also include `team`, `speedKph`, `distanceMeters`, `gapAheadMeters`, `gapAheadSeconds`, `intervalAheadSeconds`, `leaderGapSeconds`, `gapAheadLaps`, `intervalAheadLaps`, `leaderGapLaps`, `finished`, `finishTime`, `finishRank`, `status`, `raceStatus`, `wavedFlag`, `destroyed`, `destroyReason`, `destroyedAt`, `dnf`, `dnfReason`, `dnfAt`, `dnfOrder`, `penaltySeconds`, `adjustedFinishTime`, `classifiedRank`, and `lapTelemetry`. A car that has crossed the finish before the whole race is complete exposes `status: 'waved-flag'`, `raceStatus: 'waved-flag'`, and `wavedFlag: true`; the timing order keeps those cars frozen by provisional `finishRank` until final classification is available, and later barrier contact or stalled-off-track detection does not convert that result into DNF. A destroyed/out-of-race car exposes `dnf: true`, keeps compatibility `raceStatus: 'destroyed'`, shows `DNF` in the timing tower, renders faded/gray in the race canvas, and is ordered below running cars by `dnfOrder`. Stalled off-track DNFs use `dnfReason: 'stalled-off-track'` and `destroyed: false`; barrier DNFs keep `destroyed: true`. Classification entries also expose `gapLaps`, `intervalLaps`, `positionDrop`, `disqualified`, and DNF metadata. `gapAheadSeconds` and `intervalAheadSeconds` are the interval to the car directly ahead when both cars are on the same lead-lap cycle; `gapAheadLaps` and `intervalAheadLaps` are the whole-lap deficit to that car when it is at least one lap ahead. `leaderGapSeconds` is the same-lead-lap gap to P1. `leaderGapLaps` is the whole-lap deficit to P1. Seconds gaps are measured from the timestamp difference when both cars crossed the same hidden timing line, with fallback interpolation only when no shared timing line exists yet. The timing tower prefers lap labels such as `+1` over seconds when the relevant lap gap is positive, shows `WAVED` for a finished car before full race classification, and shows `DNF` for retired cars. The first car to finish sets a provisional `raceControl.winner` and receives a `car-finish` event, but the race keeps running until every race participant has finished or is DNF. A DNF car restored before final classification re-enters live timing and must finish; after final classification, resurrection does not reopen the race. Final `raceControl.classification` sorts finishers by `finishTime + penaltySeconds` after converting unserved drive-through and stop-go penalties, applies position-drop and disqualification consequences, then appends DNF entries after finishers.
711
1004
 
712
1005
  When `rules.modules.pitStops.enabled` is true, cars also expose `pitIntent`, `pitStop`, and `usedTireCompounds`. `pitIntent` and `pitStop.intent` use `0` for no request, `1` for an opportunistic request that stays active until a free-enough entry window appears, and `2` for a committed request that enters at the next pit-entry window even if pit capacity or gap checks would block mode `1`. `pitStop.status` is one of `pending`, `entering`, `queued`, `servicing`, `exiting`, or `completed`; `pitStop.phase` may be `entry`, `queue`, `queue-release`, `penalty`, `service`, `exit`, or `null`. It also includes the assigned shared service `boxId`/`boxIndex`, the driver's `garageBoxId`/`garageBoxIndex`, optional team id/color, planned pit-call race distance, physical pit-entry race distance, service seconds remaining, `penaltyServiceRemainingSeconds`, `penaltyServiceTotalSeconds`, `servingPenaltyIds`, target tire, optional `serviceProfile`, queue flag, and completed stop count. Host APIs and expert actions can choose the target tire compound with `setPitIntent(driverId, intent, targetCompound)` or `pitCompound`; invalid compounds are rejected. Automatic pit calls can share the same entry lap, but a pending mode `1` car only joins when the active pit-lane population is below `maxConcurrentPitLaneCars` and the nearest active pit-lane car is at least `minimumPitLaneGapMeters` ahead; mode `2` bypasses that opportunistic-capacity gate and commits to the automatic pit-entry route. Team-mates share one service area; every car enters through the working-lane queue point first. If the service area is free, the queue point behaves as a rolling gate and the car immediately follows a short `queue-release` route into service without exposing `queued`; if the area is occupied, the car exposes `status: queued` until servicing, queue-release, and just-exiting cars have physically cleared the active spot. Completed stops can be re-armed by later tire condition or host intent, so `stopsCompleted` can increase beyond one during longer races. `pitLaneSpeedLimitKph` applies only while the automatic route is inside the straight main pit lane/working lane; entry and exit connector roads remain legal pit surfaces but are not limiter zones. The automatic route brakes before the main lane start, travels along the main fast lane, and crosses into the team service area only near the assigned stop. If `phase` is `penalty`, the car is stationary before tire service and the browser renderer shows a red `+Ns` countdown above the car; if `phase` is `service`, it shows a yellow `Ns` countdown for the normal pit-service time. Optional pit-stop variability records the resolved `serviceProfile`; `pitStops.variability.perfect: true` forces the configured default service time for deterministic training. `usedTireCompounds` starts with the initial tire and receives the tire selected by completed automatic pit stops.
713
1006
 
@@ -743,7 +1036,7 @@ Every built track is automatically divided into three equal sectors. `snapshot.t
743
1036
 
744
1037
  Every built track also exposes hidden `snapshot.track.timingLines`. Timing lines are spaced from the track length at an F1-style mini-sector target of roughly `150m..200m`; they are simulation metadata for gap calculation and are not rendered by default.
745
1038
 
746
- Every built track also exposes `snapshot.track.pitLane`. The pit lane is deterministic for the track seed and contains:
1039
+ Tracks whose resolved generation options enable pit lanes expose `snapshot.track.pitLane`. The `race` profile includes this geometry; training profiles are pitless by default unless explicitly overridden. When present, the pit lane is deterministic for the track seed and contains:
747
1040
 
748
1041
  - `entry`: track distance before the start line, the true track `edgePoint`, an overlapping lane-facing `trackConnectPoint` on the track surface, connector points from the racing surface to the pit lane, and a procedural `roadCenterline` that is tangent to the main track at entry and tangent to the straight pit lane at the pit-lane start.
749
1042
  - `layout`: the model-owned pit sizing data, including the box-run length, total main-lane length, entry/exit distances relative to start/finish, and entry/exit buffers. The main lane is sized from the configured team/box count instead of using a fixed oversized straight.
@@ -807,9 +1100,10 @@ Controller methods:
807
1100
  - `callSafetyCar()`: deploys the safety car.
808
1101
  - `clearSafetyCar()`: releases the safety car.
809
1102
  - `toggleSafetyCar()`: switches safety car deployment based on the current snapshot.
810
- - `setPitIntent(driverId, intent, targetCompound?)`: requests, clears, or updates a pending automatic pit stop and optional target tire.
1103
+ - `setPitIntent(driverId, intent, targetCompound?)`: requests, clears, or updates a pending automatic pit stop and optional target tire. This requires `rules.modules.pitStops.enabled: true` and track pit-lane geometry; `tireStrategy` alone only affects compound choices.
811
1104
  - `getPitIntent(driverId)`: reads the current pit intent.
812
1105
  - `getPitTargetCompound(driverId)`: reads the current pit target tire.
1106
+ - `getSimulationSpeed()`: returns the active browser playback multiplier from the package-owned simulation-speed control, defaulting to `1`.
813
1107
  - `servePenalty(penaltyId)`: marks an issued drive-through or stop-go penalty as served.
814
1108
  - `cancelPenalty(penaltyId)`: cancels a penalty so it no longer affects service, timing, grid, or classification.
815
1109
  - `getSnapshot()`: returns the latest simulation snapshot.
@@ -829,6 +1123,7 @@ Composable controllers additionally expose:
829
1123
  - `mountRaceTelemetryDrawer(root, { timingTowerVerticalFit, drawerInitiallyOpen, raceDataTelemetryDetail })`: renders a template that combines an external top control row, race canvas, embedded timing tower, the project/radio lower-third, top steward message, safety-car control, and a right-side telemetry drawer. The control row contains camera controls, the `1x..10x` simulation-speed toggle, banner mute, the safety-car button, and the telemetry toggle so those controls do not cover the race view. Pass `raceDataTelemetryDetail: true` when this template should put compact S1/S2/S3 detail in the project lower-third instead of mounting a second sector popup. The drawer embeds the same package-owned telemetry stack used by `mountTelemetryPanel()` and takes layout space from the race window when opened.
830
1124
  - `mountCarDriverOverview(root)`: renders the package-owned car/driver overview as a separate component with a Car/Driver toggle, center visual, and linked stat cells from the existing driver/vehicle rating components.
831
1125
  - `mountRaceDataPanel(root)`: renders the project/race-data lower-third as a separate component for hosts that intentionally want it outside the race canvas.
1126
+ - `querySelector(selector)` / `querySelectorAll(selector)`: search across the mounted package-owned composable roots. These exist for integration tests and advanced host glue; ordinary hosts should prefer explicit controller methods and mounted component roots.
832
1127
  - `start()`: initializes PixiJS, binds mounted controls, and starts the simulation loop.
833
1128
 
834
1129
  Mount component roots before calling `start()`. If a component is not mounted, the runtime skips that UI surface instead of requiring hidden placeholder DOM. Mounted surfaces render a package-owned loading overlay immediately; `start()` removes those overlays after PixiJS, assets, controls, and initial readouts have initialized.
@@ -141,7 +141,7 @@ constructorArgs: {
141
141
 
142
142
  It also copies the converted driver fields and vehicle fields onto the object used by `createRaceSimulation()`.
143
143
 
144
- Team metadata is copied onto the same normalized driver object. `team.color` defaults to the driver/car color when omitted, and `team.icon` defaults from the team name or timing code. Teams are metadata-only for now, but they are intentionally modeled separately from driver and vehicle data so future pit boxes, pit colors, team strategy, and team-owned fields do not have to be forced into car or driver objects.
144
+ Team metadata is copied onto the same normalized driver object. `team.color` defaults to the driver/car color when omitted, and `team.icon` defaults from the team name or timing code. Teams are intentionally modeled separately from driver and vehicle data because current pit-lane service areas, queue colors, garage box identity, optional pit-crew stats, and future team-owned fields should not be forced into car or driver objects.
145
145
 
146
146
  ## Unit Conversion
147
147