@inventure71/paddockjs 1.0.0 → 2.0.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 (190) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/INSTALL_AND_UPDATE.md +4 -3
  3. package/README.md +194 -12
  4. package/docs/architecture.md +119 -44
  5. package/docs/concepts.md +4 -2
  6. package/docs/custom_model_controller.md +329 -0
  7. package/docs/data_contract.md +299 -17
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +22 -0
  10. package/docs/rules.md +37 -10
  11. package/docs/system_specs.md +23 -10
  12. package/docs/training.md +272 -30
  13. package/examples/python/README.md +62 -0
  14. package/examples/python/base_policy_server.py +191 -0
  15. package/examples/python/requirements.txt +2 -0
  16. package/package.json +12 -4
  17. package/src/api/PaddockSimulatorController.js +4 -0
  18. package/src/app/BrowserExpertAdapter.js +367 -9
  19. package/src/app/F1SimulatorApp.js +305 -1603
  20. package/src/app/banners/bannerFormatters.js +55 -0
  21. package/src/app/banners/raceDataBannerController.js +141 -0
  22. package/src/app/banners/stewardMessageController.js +99 -0
  23. package/src/app/camera/cameraConstants.js +21 -0
  24. package/src/app/camera/cameraController.js +265 -0
  25. package/src/app/camera/cameraGeometry.js +176 -0
  26. package/src/app/domBindings.js +4 -0
  27. package/src/app/readouts/carOverviewRenderer.js +109 -0
  28. package/src/app/readouts/controlStateRenderer.js +37 -0
  29. package/src/app/readouts/raceStatusRenderer.js +133 -0
  30. package/src/app/readouts/readoutFormatters.js +58 -0
  31. package/src/app/readouts/telemetryRenderer.js +113 -0
  32. package/src/app/readouts/timingTowerRenderer.js +177 -0
  33. package/src/app/rendering/appAssets.js +38 -0
  34. package/src/app/rendering/carRenderer.js +286 -0
  35. package/src/app/rendering/displayUtils.js +22 -0
  36. package/src/app/rendering/drsTrailRenderer.js +90 -0
  37. package/src/app/rendering/expertSensorRenderer.js +126 -0
  38. package/src/app/rendering/pitLaneStatusRenderer.js +50 -0
  39. package/src/app/rendering/replayGhostRenderer.js +124 -0
  40. package/src/app/rendering/trackRenderer.js +25 -0
  41. package/src/app/runtime/frameLoop.js +66 -0
  42. package/src/app/runtime/runtimeTiming.js +34 -0
  43. package/src/app/runtime/runtimeVisibility.js +45 -0
  44. package/src/config/defaultOptions.js +20 -0
  45. package/src/environment/actions.js +13 -5
  46. package/src/environment/controllerLoop.js +246 -0
  47. package/src/environment/episode.js +86 -2
  48. package/src/environment/evaluation.js +54 -32
  49. package/src/environment/index.d.ts +361 -9
  50. package/src/environment/index.js +1 -0
  51. package/src/environment/metrics.js +159 -0
  52. package/src/environment/observationOptions.js +22 -1
  53. package/src/environment/observations.js +325 -40
  54. package/src/environment/options.js +37 -5
  55. package/src/environment/runtime.js +222 -21
  56. package/src/environment/scenarios.js +37 -8
  57. package/src/environment/sensors/bodySenses.js +23 -0
  58. package/src/environment/sensors/boundarySenses.js +17 -0
  59. package/src/environment/sensors/carRays.js +75 -0
  60. package/src/environment/sensors/contactSenses.js +32 -0
  61. package/src/environment/sensors/index.js +157 -0
  62. package/src/environment/sensors/indexedRayBands.js +103 -0
  63. package/src/environment/sensors/nearbyCars.js +52 -0
  64. package/src/environment/sensors/opponentRadar.js +65 -0
  65. package/src/environment/sensors/rayConfig.js +110 -0
  66. package/src/environment/sensors/rayDefaults.js +6 -0
  67. package/src/environment/sensors/rayGeometry.js +60 -0
  68. package/src/environment/sensors/rayGuards.js +19 -0
  69. package/src/environment/sensors/sensorTargets.js +60 -0
  70. package/src/environment/sensors/surfaceRays.js +296 -0
  71. package/src/environment/sensors/trackRays.js +261 -0
  72. package/src/environment/sensors.js +1 -367
  73. package/src/environment/specs.js +100 -5
  74. package/src/environment/workerProtocol.js +13 -1
  75. package/src/index.d.ts +169 -6
  76. package/src/index.js +5 -0
  77. package/src/rendering/proceduralTrackAsset.js +20 -823
  78. package/src/rendering/renderSnapshot.js +38 -12
  79. package/src/rendering/track/finishLineRenderer.js +34 -0
  80. package/src/rendering/track/gridRenderer.js +47 -0
  81. package/src/rendering/track/offsetStrokeSafety.js +136 -0
  82. package/src/rendering/track/pitLaneRenderer.js +248 -0
  83. package/src/rendering/track/trackMaterialRenderer.js +326 -0
  84. package/src/rendering/track/trackRenderConstants.js +38 -0
  85. package/src/rendering/track/trackRenderGeometry.js +94 -0
  86. package/src/simulation/collisionGeometry.js +1 -1
  87. package/src/simulation/driver/driverControlConstants.js +28 -0
  88. package/src/simulation/driver/driverController.js +47 -0
  89. package/src/simulation/driver/driverInput.js +28 -0
  90. package/src/simulation/driver/driverMath.js +6 -0
  91. package/src/simulation/driver/driverPersonality.js +25 -0
  92. package/src/simulation/driver/edgeRecovery.js +48 -0
  93. package/src/simulation/driver/racingControls.js +399 -0
  94. package/src/simulation/driver/racingLinePlan.js +166 -0
  95. package/src/simulation/driver/recoveryDynamics.js +38 -0
  96. package/src/simulation/driver/rejoinControls.js +213 -0
  97. package/src/simulation/driver/safetyCarControls.js +35 -0
  98. package/src/simulation/driver/trafficScan.js +51 -0
  99. package/src/simulation/driverController.js +4 -619
  100. package/src/simulation/participants/participantInteractions.js +120 -0
  101. package/src/simulation/pit/pitFlow.js +234 -0
  102. package/src/simulation/pit/pitIntent.js +92 -0
  103. package/src/simulation/pit/pitOccupancy.js +47 -0
  104. package/src/simulation/pit/pitPenaltyService.js +71 -0
  105. package/src/simulation/pit/pitQueue.js +55 -0
  106. package/src/simulation/pit/pitRouteMovement.js +160 -0
  107. package/src/simulation/pit/pitRouting.js +319 -0
  108. package/src/simulation/pit/pitService.js +41 -0
  109. package/src/simulation/pit/pitServiceConstants.js +21 -0
  110. package/src/simulation/pit/pitServiceProfile.js +45 -0
  111. package/src/simulation/pit/pitSnapshots.js +61 -0
  112. package/src/simulation/pit/pitState.js +113 -0
  113. package/src/simulation/pit/pitTireService.js +92 -0
  114. package/src/simulation/race/classification.js +104 -0
  115. package/src/simulation/race/gridPenalties.js +37 -0
  116. package/src/simulation/race/raceControlState.js +38 -0
  117. package/src/simulation/race/raceDistance.js +32 -0
  118. package/src/simulation/race/raceFinish.js +65 -0
  119. package/src/simulation/race/raceLifecycle.js +71 -0
  120. package/src/simulation/race/raceOrder.js +113 -0
  121. package/src/simulation/race/raceProgress.js +124 -0
  122. package/src/simulation/race/raceSetup.js +93 -0
  123. package/src/simulation/race/raceStep.js +80 -0
  124. package/src/simulation/race/redFlag.js +15 -0
  125. package/src/simulation/race/retirements.js +44 -0
  126. package/src/simulation/race/safetyCar.js +86 -0
  127. package/src/simulation/race/stalledDnf.js +86 -0
  128. package/src/simulation/raceSimulation.js +230 -2817
  129. package/src/simulation/replay/replayGhosts.js +128 -0
  130. package/src/simulation/rules/moduleConfig.js +72 -0
  131. package/src/simulation/rules/penaltyConfig.js +99 -0
  132. package/src/simulation/rules/penaltyEvents.js +18 -0
  133. package/src/simulation/rules/penaltyStats.js +26 -0
  134. package/src/simulation/rules/ruleConfigMerge.js +41 -0
  135. package/src/simulation/rules/ruleDefaults.js +85 -0
  136. package/src/simulation/rules/rulesReview.js +141 -0
  137. package/src/simulation/rules/rulesetPresets.js +47 -0
  138. package/src/simulation/rules/trackLimitsSteward.js +1 -1
  139. package/src/simulation/rulesConfig.js +5 -323
  140. package/src/simulation/snapshots/raceSnapshots.js +192 -0
  141. package/src/simulation/timing/drsTiming.js +87 -0
  142. package/src/simulation/timing/gapEstimation.js +38 -0
  143. package/src/simulation/timing/lapTelemetry.js +182 -0
  144. package/src/simulation/timing/raceTiming.js +15 -0
  145. package/src/simulation/timing/sectorPerformance.js +60 -0
  146. package/src/simulation/timing/timingConstants.js +5 -0
  147. package/src/simulation/timing/timingHistory.js +46 -0
  148. package/src/simulation/timing/timingLines.js +79 -0
  149. package/src/simulation/track/centerlineSpline.js +59 -0
  150. package/src/simulation/track/drsZones.js +67 -0
  151. package/src/simulation/track/pitLaneAccess.js +103 -0
  152. package/src/simulation/track/pitLaneLayout.js +395 -0
  153. package/src/simulation/track/pitLaneState.js +160 -0
  154. package/src/simulation/track/proceduralCenterline.js +237 -0
  155. package/src/simulation/track/sampleGeometry.js +49 -0
  156. package/src/simulation/track/spatialQueries.js +154 -0
  157. package/src/simulation/track/startStraight.js +141 -0
  158. package/src/simulation/track/trackConstants.js +109 -0
  159. package/src/simulation/track/trackGenerationOptions.js +283 -0
  160. package/src/simulation/track/trackMath.js +241 -0
  161. package/src/simulation/track/trackModel.js +240 -0
  162. package/src/simulation/track/trackQueryIndex.js +945 -0
  163. package/src/simulation/track/trackSectors.js +19 -0
  164. package/src/simulation/track/trackStatePolicy.js +24 -0
  165. package/src/simulation/track/trackValidation.js +98 -0
  166. package/src/simulation/trackModel.js +1 -1706
  167. package/src/simulation/vehicle/contactResolution.js +116 -0
  168. package/src/simulation/vehicle/mainTrackWheelSurface.js +82 -0
  169. package/src/simulation/vehicle/pitWheelSurface.js +69 -0
  170. package/src/simulation/vehicle/runoffResponse.js +88 -0
  171. package/src/simulation/vehicle/surfacePriority.js +28 -0
  172. package/src/simulation/vehicle/vehicleGeometry.js +247 -0
  173. package/src/simulation/vehicle/vehiclePhysics.js +363 -0
  174. package/src/simulation/vehicle/vehicleSnapshots.js +316 -0
  175. package/src/simulation/vehicle/vehicleState.js +186 -0
  176. package/src/simulation/vehicle/wheelSurface.js +150 -0
  177. package/src/simulation/vehicleGeometry.js +1 -247
  178. package/src/simulation/vehiclePhysics.js +1 -136
  179. package/src/simulation/warmup/runtimeWarmup.js +224 -0
  180. package/src/simulation/wheelSurface.js +1 -338
  181. package/src/styles.css +42 -0
  182. package/src/ui/cameraControlsTemplate.js +20 -0
  183. package/src/ui/carOverviewTemplate.js +33 -0
  184. package/src/ui/componentTemplates.js +14 -360
  185. package/src/ui/raceCanvasTemplate.js +69 -0
  186. package/src/ui/raceControlsTemplate.js +36 -0
  187. package/src/ui/shellTemplate.js +5 -4
  188. package/src/ui/telemetryDrawerTemplate.js +55 -0
  189. package/src/ui/telemetryTemplates.js +136 -0
  190. package/src/ui/timingTowerTemplate.js +34 -0
@@ -14,8 +14,13 @@ mountF1Simulator(root, {
14
14
  onDriverOpen,
15
15
  seed,
16
16
  trackSeed,
17
+ trackQueryIndex,
18
+ warmup,
17
19
  totalLaps,
20
+ physicsMode,
18
21
  rules,
22
+ participantInteractions,
23
+ replayGhosts,
19
24
  initialCameraMode,
20
25
  theme,
21
26
  title,
@@ -45,8 +50,13 @@ const simulator = createPaddockSimulator({
45
50
  onDriverOpen,
46
51
  seed,
47
52
  trackSeed,
53
+ trackQueryIndex,
54
+ warmup,
48
55
  totalLaps,
56
+ physicsMode,
49
57
  rules,
58
+ participantInteractions,
59
+ replayGhosts,
50
60
  initialCameraMode,
51
61
  preset,
52
62
  theme,
@@ -121,6 +131,9 @@ result = env.step({
121
131
  ```
122
132
 
123
133
  `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.
134
+ `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.
135
+ `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.
136
+ `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: `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.
124
137
  `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
138
 
126
139
  ```js
@@ -135,6 +148,14 @@ rules: {
135
148
  tirePitRequestThresholdPercent: 50,
136
149
  tirePitCommitThresholdPercent: 30,
137
150
  },
151
+ tireDegradation: {
152
+ enabled: false,
153
+ },
154
+ stalledDnf: {
155
+ enabled: true,
156
+ maxStoppedSeconds: 12,
157
+ speedThresholdKph: 5,
158
+ },
138
159
  penalties: {
139
160
  trackLimits: { strictness: 0.8 },
140
161
  collision: { strictness: 0.5, consequences: [{ type: 'time', seconds: 5 }] },
@@ -145,7 +166,7 @@ rules: {
145
166
  }
146
167
  ```
147
168
 
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.
169
+ 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.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'`. 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
170
 
150
171
  Scenario support:
151
172
 
@@ -175,7 +196,65 @@ scenario: {
175
196
  }
176
197
  ```
177
198
 
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.
199
+ 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.
200
+
201
+ `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. 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. This API is not accepted inside `step(actions)`.
202
+
203
+ 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:
204
+
205
+ ```js
206
+ participantInteractions: {
207
+ defaultProfile: 'normal',
208
+ drivers: {
209
+ 'model-a': { profile: 'normal' },
210
+ 'model-b': { profile: 'isolated-training' },
211
+ },
212
+ }
213
+ ```
214
+
215
+ Supported profiles are:
216
+
217
+ - `normal`: collidable, ray-detectable, nearby-detectable, pit-lane-blocking, and race-order-affecting.
218
+ - `isolated-training`: non-colliding, hidden from rays and nearby-car observations, non-blocking in pit-lane occupancy, but still included in race order.
219
+ - `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`.
220
+ - `phantom-race`: non-colliding and non-blocking in pit-lane occupancy, but visible to rays and nearby-car observations and included in race order.
221
+ - `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`.
222
+
223
+ 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.
224
+
225
+ 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`.
226
+
227
+ 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.
228
+
229
+ Replay ghosts are separate trajectory-driven reference entities:
230
+
231
+ ```js
232
+ replayGhosts: [
233
+ {
234
+ id: 'best-lap',
235
+ label: 'Best Lap',
236
+ color: '#00ff84',
237
+ opacity: 0.35,
238
+ visible: true,
239
+ trajectory: [
240
+ {
241
+ timeSeconds: 0,
242
+ x: 100,
243
+ y: 200,
244
+ headingRadians: 1.2,
245
+ speedKph: 180,
246
+ progressMeters: 0,
247
+ },
248
+ ],
249
+ sensors: {
250
+ detectableByRays: false,
251
+ detectableAsNearby: false,
252
+ },
253
+ },
254
+ ]
255
+ ```
256
+
257
+ 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.
179
258
 
180
259
  Actions use normalized low-level controls:
181
260
 
@@ -189,13 +268,69 @@ Actions use normalized low-level controls:
189
268
  }
190
269
  ```
191
270
 
192
- `steering` maps to the simulator's internal steering limit. `throttle` and `brake` are clamped from `0` to `1`.
271
+ Browser expert mode also exposes an external render hook for authoritative node-frame visualization:
272
+
273
+ ```js
274
+ simulator.expert.attachExternalRenderer(source);
275
+ simulator.expert.detachExternalRenderer();
276
+ const state = simulator.expert.getExternalRendererState();
277
+ ```
278
+
279
+ `source` contract:
280
+
281
+ ```js
282
+ {
283
+ subscribe(onFrame) {
284
+ // call onFrame({ snapshot, observation, meta? })
285
+ return () => { /* unsubscribe */ };
286
+ },
287
+ }
288
+ ```
289
+
290
+ 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/`.
291
+
292
+ Operationally this is one architecture with two modes:
293
+
294
+ - local expert mode: browser owns stepping
295
+ - external render mode: browser only renders authoritative external frames
296
+
297
+ 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.
298
+
299
+ `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
300
 
194
301
  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
302
 
196
303
  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
304
 
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.
305
+ 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.
306
+
307
+ ```js
308
+ import { createPaddockDriverControllerLoop } from '@inventure71/paddockjs';
309
+
310
+ const controller = {
311
+ async init(ctx) {
312
+ this.model = await loadUserModel(ctx.observationSpec);
313
+ },
314
+ async reset(ctx) {
315
+ this.hidden = resetHiddenState(ctx.controlledDrivers.length);
316
+ },
317
+ async decideBatch(ctx) {
318
+ const vectors = ctx.orderedObservations.map((item) => item.vector);
319
+ return this.model.predictBatch(vectors, this.hidden);
320
+ },
321
+ onStep(ctx) {
322
+ recordUserOwnedStats(ctx.metrics, ctx.actions);
323
+ },
324
+ };
325
+
326
+ const loop = createPaddockDriverControllerLoop({
327
+ runtime: env,
328
+ controller,
329
+ actionRepeat: 4,
330
+ });
331
+ ```
332
+
333
+ 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
334
 
200
335
  The environment exposes specs for external training code:
201
336
 
@@ -204,7 +339,51 @@ const actionSpec = env.getActionSpec();
204
339
  const observationSpec = env.getObservationSpec();
205
340
  ```
206
341
 
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]`.
342
+ `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.
343
+
344
+ Observation output can be compacted for training throughput:
345
+
346
+ ```js
347
+ observation: {
348
+ profile: 'physical-driver',
349
+ output: 'vector', // 'full' | 'vector' | 'object'
350
+ includeSchema: false,
351
+ vectorType: 'float32', // 'array' | 'float32'
352
+ }
353
+ ```
354
+
355
+ 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. `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.
356
+
357
+ Result state output can also be compacted:
358
+
359
+ ```js
360
+ result: {
361
+ stateOutput: 'none', // 'full' | 'minimal' | 'none'
362
+ resetDriversObservationScope: 'reset', // 'all' | 'reset'
363
+ }
364
+ ```
365
+
366
+ 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 and metrics only for reset drivers by default; callers can override per call with `env.resetDrivers(placements, { observationScope: 'all' | 'reset', stateOutput })`. 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.
367
+
368
+ The JSON worker protocol exposes the same reset-result controls through `resultOptions`:
369
+
370
+ ```js
371
+ protocol.handle({
372
+ id: 'reset-agent-3',
373
+ type: 'resetDrivers',
374
+ placements: {
375
+ 'agent-3': { distanceMeters: 1200, offsetMeters: 0, speedKph: 80 },
376
+ },
377
+ resultOptions: {
378
+ observationScope: 'reset',
379
+ stateOutput: 'none',
380
+ },
381
+ });
382
+ ```
383
+
384
+ `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.
385
+
386
+ This is still an environment-control message, not an action. A policy cannot submit `resetDrivers` inside `step(actions)`.
208
387
 
209
388
  `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
389
 
@@ -229,15 +408,18 @@ const reward = createProgressReward({
229
408
  });
230
409
  ```
231
410
 
232
- Custom reward functions receive the same context:
411
+ Custom reward functions receive the same context plus neutral environment facts:
233
412
 
234
413
  ```js
235
- reward({ driverId, previous, current, action, events, state }) {
236
- return current.object.self.speedKph / 100;
414
+ reward({ driverId, previous, current, action, events, state, metrics, episode }) {
415
+ if (metrics.destroyed) return -200;
416
+ if (metrics.offTrack) return -12;
417
+ if (episode.terminated) return 0;
418
+ return metrics.legalProgressDeltaMeters;
237
419
  }
238
420
  ```
239
421
 
240
- If no reward callback is provided, `result.reward` is `null`. PaddockJS does not infer, select, or tune rewards.
422
+ `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. 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`. PaddockJS does not infer, select, or tune rewards.
241
423
 
242
424
  Neutral rollout recording is available for external training loops:
243
425
 
@@ -266,7 +448,7 @@ const report = runEnvironmentEvaluation({
266
448
  });
267
449
  ```
268
450
 
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.
451
+ 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
452
 
271
453
  `step(actions)` returns a JavaScript object designed for environment loops:
272
454
 
@@ -300,6 +482,16 @@ Evaluation reports include distance, lap progress, off-track step count, contact
300
482
  pitStopPenaltyServiceRemainingSeconds,
301
483
  pitStopsCompleted,
302
484
  },
485
+ trackRelation: {
486
+ lateralOffsetMeters,
487
+ headingErrorRadians,
488
+ legalWidthMeters,
489
+ leftBoundaryMeters,
490
+ rightBoundaryMeters,
491
+ onLegalSurface,
492
+ surface,
493
+ },
494
+ contactPatches,
303
495
  race: {
304
496
  position,
305
497
  totalCars,
@@ -316,6 +508,23 @@ Evaluation reports include distance, lap progress, off-track step count, contact
316
508
  },
317
509
  },
318
510
  reward,
511
+ metrics: {
512
+ budget: {
513
+ progressDeltaMeters,
514
+ legalProgressDeltaMeters,
515
+ offTrack,
516
+ kerb,
517
+ fullyOutsideWhiteLine,
518
+ severeCut,
519
+ destroyed,
520
+ destroyReason,
521
+ under30kph,
522
+ spinOrBackwards,
523
+ completedLap,
524
+ lapTimeSeconds,
525
+ contactCount,
526
+ },
527
+ },
319
528
  terminated,
320
529
  truncated,
321
530
  done,
@@ -331,11 +540,29 @@ Evaluation reports include distance, lap progress, off-track step count, contact
331
540
  controlledDrivers,
332
541
  actionErrors,
333
542
  endReason,
543
+ drivers: {
544
+ budget: {
545
+ terminated,
546
+ truncated,
547
+ endReason,
548
+ episodeStep,
549
+ episodeId,
550
+ },
551
+ },
334
552
  },
335
553
  }
336
554
  ```
337
555
 
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.
556
+ `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:
557
+
558
+ ```txt
559
+ snapshot.cars = physics-driven race participants
560
+ snapshot.replayGhosts = trajectory-driven replay/reference entities
561
+ ```
562
+
563
+ 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.
564
+
565
+ 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, with `surfaceCode` intended only as a compact vector encoding.
339
566
 
340
567
  Default rays use a compact center-origin set with forward, side, and rear awareness:
341
568
 
@@ -350,11 +577,18 @@ Track distances are sampled against the actual track model. When the ray starts
350
577
  angleDegrees,
351
578
  angleRadians,
352
579
  lengthMeters,
580
+ roadEdge: {
581
+ hit,
582
+ distanceMeters,
583
+ kind, // 'exit' | 'entry' | null
584
+ },
353
585
  track: {
354
586
  hit,
355
587
  distanceMeters,
356
588
  kind, // 'exit' | 'entry' | null
357
589
  },
590
+ kerb: { hit, distanceMeters, surface },
591
+ illegalSurface: { hit, distanceMeters, surface },
358
592
  car: {
359
593
  hit,
360
594
  distanceMeters,
@@ -364,6 +598,26 @@ Track distances are sampled against the actual track model. When the ray starts
364
598
  }
365
599
  ```
366
600
 
601
+ `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:
602
+
603
+ ```js
604
+ sensors: {
605
+ rays: {
606
+ layout: 'driver-front-heavy',
607
+ channels: ['roadEdge', 'kerb', 'illegalSurface', 'car'],
608
+ precision: 'driver', // 'driver' | 'debug'
609
+ rays: [
610
+ { id: 'front', angleDegrees: 0, lengthMeters: 260 },
611
+ { id: 'right', angleDegrees: 90, lengthMeters: 80 },
612
+ ],
613
+ },
614
+ }
615
+ ```
616
+
617
+ 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.
618
+
619
+ Surface channels are computed only when requested. `kerb` is legal racing surface. `illegalSurface` reports the first grass or gravel surface crossed by the ray. The same model-facing ray contract is accelerated for both normal on-track driving and nearby off-track recovery starts using indexed ray-boundary intersections. Ambiguous pit-connector and unusual geometry cases may use indexed 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.
620
+
367
621
  Nearby-car observations are car-relative:
368
622
 
369
623
  ```js
@@ -375,11 +629,16 @@ Nearby-car observations are car-relative:
375
629
  relativeSpeedKph,
376
630
  relativeHeadingRadians,
377
631
  ahead,
632
+ behind,
378
633
  sameLap,
634
+ closingRateMetersPerSecond,
635
+ timeToContactSeconds,
636
+ leftOverlap,
637
+ rightOverlap,
379
638
  }
380
639
  ```
381
640
 
382
- If no `reward` callback is provided, `result.reward` is `null`. If provided, rewards are returned by controlled driver ID.
641
+ 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`.
383
642
 
384
643
  Browser expert mode is opt-in through the browser mount API:
385
644
 
@@ -405,7 +664,7 @@ simulator.expert.step({
405
664
 
406
665
  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
666
 
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.
667
+ `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
668
 
410
669
  ## Lap Telemetry Snapshot
411
670
 
@@ -442,7 +701,25 @@ Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timin
442
701
 
443
702
  `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
703
 
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.
704
+ `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.
705
+
706
+ `trackGeneration` is optional and applies only when a procedural `trackSeed` is used. It accepts profile presets plus explicit overrides:
707
+
708
+ ```js
709
+ {
710
+ profile: 'training-short',
711
+ length: { minMeters: 900, maxMeters: 1800 },
712
+ startStraight: { gridMeters: 0, exitMeters: 80, blendMeters: 80 },
713
+ pitLane: { enabled: false },
714
+ shape: { scale: 0.2, cornerDensity: 1.3, variation: 0.22 },
715
+ validation: { minClearanceMultiplier: 1, maxLocalTurnRadians: 1.85 },
716
+ attempts: { primary: 80, fallback: 200 },
717
+ }
718
+ ```
719
+
720
+ 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.
721
+
722
+ `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
723
 
447
724
  `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
725
 
@@ -577,7 +854,7 @@ Optional lifecycle callbacks:
577
854
  }
578
855
  ```
579
856
 
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.
857
+ `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`. Host callback errors are caught; if `onError` exists, it receives `{ callback: name }` context for callback failures.
581
858
 
582
859
  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
860
 
@@ -646,6 +923,9 @@ ui: {
646
923
  raceDataBannerSize: 'custom',
647
924
  raceDataTelemetryDetail: false,
648
925
  timingTowerVerticalFit: 'expand-race-view',
926
+ },
927
+ debug: {
928
+ physicsModeIndicator: false,
649
929
  }
650
930
  ```
651
931
 
@@ -654,6 +934,7 @@ ui: {
654
934
  - `showFps`: controls whether the race canvas renders the FPS readout.
655
935
  - `showRaceDataPanel`: controls whether the precombined shell includes the project/radio lower-third inside the race window.
656
936
  - `showTimingTower`, `showTelemetry`: reserved component visibility flags for host layout decisions.
937
+ - `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
938
  - `telemetryIncludesOverview`: controls whether the telemetry stack template embeds the car/driver overview. Composable hosts can also pass `mountTelemetryPanel(root, { includeOverview: false })`.
658
939
  - `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
940
  - `raceDataBanners.initial`: `'project'`, `'radio'`, or `'hidden'`. This controls which lower-third appears first in the precombined shell.
@@ -701,13 +982,13 @@ After every car completes `totalLaps`, `getSnapshot()` returns:
701
982
  finishedAt: 123.4,
702
983
  winner: { id, code, name, rank, finished },
703
984
  classification: [
704
- { id, code, timingCode, name, rank, lap, lapsCompleted, distanceMeters, gapMeters, gapSeconds, intervalSeconds, gapLaps, intervalLaps, finished, finishTime, penaltySeconds, adjustedFinishTime, positionDrop, disqualified },
985
+ { id, code, timingCode, name, rank, lap, lapsCompleted, distanceMeters, gapMeters, gapSeconds, intervalSeconds, gapLaps, intervalLaps, finished, finishTime, dnf, dnfReason, dnfAt, dnfOrder, penaltySeconds, adjustedFinishTime, positionDrop, disqualified },
705
986
  ],
706
987
  },
707
988
  }
708
989
  ```
709
990
 
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.
991
+ 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
992
 
712
993
  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
994
 
@@ -810,6 +1091,7 @@ Controller methods:
810
1091
  - `setPitIntent(driverId, intent, targetCompound?)`: requests, clears, or updates a pending automatic pit stop and optional target tire.
811
1092
  - `getPitIntent(driverId)`: reads the current pit intent.
812
1093
  - `getPitTargetCompound(driverId)`: reads the current pit target tire.
1094
+ - `getSimulationSpeed()`: returns the active browser playback multiplier from the package-owned simulation-speed control, defaulting to `1`.
813
1095
  - `servePenalty(penaltyId)`: marks an issued drive-through or stop-go penalty as served.
814
1096
  - `cancelPenalty(penaltyId)`: cancels a penalty so it no longer affects service, timing, grid, or classification.
815
1097
  - `getSnapshot()`: returns the latest simulation snapshot.
package/docs/index.md CHANGED
@@ -9,6 +9,7 @@ Use these docs as the package source of truth:
9
9
  - [Concepts](concepts.md): vocabulary used by the simulator.
10
10
  - [Data Contract](data_contract.md): shape of host-provided drivers, car pairings, ratings, assets, and callbacks.
11
11
  - [Bring Your Own Model](training.md): environment contract, policy convention, and visual playback loop.
12
+ - [Custom Model Controller Guide](custom_model_controller.md): how to wrap a trained model as a batched driver controller for browser playback or headless loops.
12
13
  - [Architecture](architecture.md): module ownership and data/control flow.
13
14
  - [Data Model Notes](data_model.md): lower-level simulator data model notes kept with package docs.
14
15
  - [Learnings](learnings.md): implementation lessons and layout verification rules learned from simulator integration issues.
package/docs/learnings.md CHANGED
@@ -45,3 +45,25 @@ For visual layout changes, verify more than the happy path screenshot:
45
45
  - Browser smoke against an installed/bundled host, not only string-based unit tests.
46
46
 
47
47
  Unit tests should cover the contract, but browser geometry checks catch CSS interactions that tests can miss.
48
+
49
+ ## Indexed Track Queries Must Reach Every Runtime Path
50
+
51
+ The track query index is internal performance infrastructure, not a separate sensor contract. When a runtime opts into it, every simulation instance used by that runtime must receive the same option:
52
+
53
+ - Headless environments created by `createPaddockEnvironment()`.
54
+ - Browser expert mode through `F1SimulatorApp.createRaceSimulation()`.
55
+ - Policy Runner controller-debug configurations.
56
+ - Local preview remount/restart paths.
57
+ - Any benchmark or smoke path used to validate training behavior.
58
+
59
+ Do not assume that setting `trackQueryIndex: true` on a high-level preview or environment config is enough. Trace the option all the way into `createRaceSimulation()` and verify that the live simulation owns a non-enumerable `track.queryIndex`. The index must stay out of public JSON contracts: it may exist on the in-memory track object, but `Object.keys(snapshot.track)` must not expose `queryIndex`.
60
+
61
+ The failure mode to avoid: headless training uses indexed queries and looks fast, while the browser visual/expert simulation silently drops the option and falls back to legacy `nearestSampleInRange()` scans near barriers, recovery starts, or far-off-track positions. This can make the Policy Runner lag even when the model, heuristic controller, and ray contract are correct.
62
+
63
+ Verification for index-related changes should include both layers:
64
+
65
+ - A focused unit test proving the app or environment path forwards `trackQueryIndex` into the created simulation.
66
+ - A browser reproduction or smoke check against the Policy Runner or relevant visual runtime, especially near barrier/recovery geometry where legacy nearest-sample scans are most expensive.
67
+ - A profile or timing check when the bug is performance-related. If `nearestSampleInRange()` is still hot in an indexed browser path, the option did not reach the live simulation or another code path is bypassing the index.
68
+
69
+ Do not fix browser performance by changing model-facing sensor precision, hiding observations, or showing debug-only values. The active observation contract remains the source of truth: policies receive it, Policy Runner visualizes it, and the index only changes how the same values are computed.