@inventure71/paddockjs 0.2.0 → 1.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 (63) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/INSTALL_AND_UPDATE.md +165 -0
  3. package/README.md +217 -20
  4. package/docs/architecture.md +84 -15
  5. package/docs/concepts.md +20 -3
  6. package/docs/data_contract.md +412 -20
  7. package/docs/data_model.md +2 -1
  8. package/docs/index.md +1 -0
  9. package/docs/learnings.md +1 -1
  10. package/docs/rules.md +140 -19
  11. package/docs/system_specs.md +74 -20
  12. package/docs/training.md +206 -0
  13. package/examples/train-basic-policy.mjs +276 -0
  14. package/examples/trainingData.mjs +133 -0
  15. package/package.json +18 -8
  16. package/src/api/PaddockSimulatorController.js +39 -4
  17. package/src/app/BrowserExpertAdapter.js +32 -0
  18. package/src/app/F1SimulatorApp.js +1251 -146
  19. package/src/app/domBindings.js +10 -2
  20. package/src/config/defaultOptions.js +11 -3
  21. package/src/data/championship.js +13 -0
  22. package/src/data/normalizeDrivers.js +7 -2
  23. package/src/environment/actions.js +68 -0
  24. package/src/environment/episode.js +17 -0
  25. package/src/environment/evaluation.js +184 -0
  26. package/src/environment/events.js +17 -0
  27. package/src/environment/index.d.ts +521 -0
  28. package/src/environment/index.js +15 -0
  29. package/src/environment/observationOptions.js +12 -0
  30. package/src/environment/observations.js +284 -0
  31. package/src/environment/options.js +157 -0
  32. package/src/environment/recorder.js +28 -0
  33. package/src/environment/rewards.js +69 -0
  34. package/src/environment/runtime.js +175 -0
  35. package/src/environment/scenarios.js +220 -0
  36. package/src/environment/sensors.js +367 -0
  37. package/src/environment/specs.js +163 -0
  38. package/src/environment/workerProtocol.js +52 -0
  39. package/src/index.d.ts +405 -6
  40. package/src/index.js +27 -2
  41. package/src/rendering/proceduralTrackAsset.js +343 -24
  42. package/src/simulation/collisionGeometry.js +214 -0
  43. package/src/simulation/driverController.js +431 -65
  44. package/src/simulation/raceSimulation.js +2140 -171
  45. package/src/simulation/rules/collisionSteward.js +69 -0
  46. package/src/simulation/rules/penaltyLedger.js +153 -0
  47. package/src/simulation/rules/pitLaneSpeedingSteward.js +71 -0
  48. package/src/simulation/rules/tireRequirementSteward.js +29 -0
  49. package/src/simulation/rules/trackLimitsSteward.js +85 -0
  50. package/src/simulation/rulesConfig.js +352 -0
  51. package/src/simulation/trackModel.js +1154 -76
  52. package/src/simulation/units.js +6 -4
  53. package/src/simulation/vehicleGeometry.js +247 -0
  54. package/src/simulation/vehiclePhysics.js +82 -50
  55. package/src/simulation/wheelSurface.js +338 -0
  56. package/src/styles.css +369 -67
  57. package/src/ui/bannerTemplates.js +69 -0
  58. package/src/ui/componentTemplates.js +45 -81
  59. package/src/ui/raceControlStatusBanner.js +39 -0
  60. package/src/ui/shellTemplate.js +1 -1
  61. package/src/ui/templateUtils.js +23 -0
  62. package/AGENTS.md +0 -86
  63. package/src/standalone.js +0 -15
@@ -15,6 +15,7 @@ mountF1Simulator(root, {
15
15
  seed,
16
16
  trackSeed,
17
17
  totalLaps,
18
+ rules,
18
19
  initialCameraMode,
19
20
  theme,
20
21
  title,
@@ -24,6 +25,7 @@ mountF1Simulator(root, {
24
25
  showBackLink,
25
26
  ui,
26
27
  assets,
28
+ expert,
27
29
  onLoadingChange,
28
30
  onReady,
29
31
  onError,
@@ -44,17 +46,18 @@ const simulator = createPaddockSimulator({
44
46
  seed,
45
47
  trackSeed,
46
48
  totalLaps,
49
+ rules,
47
50
  initialCameraMode,
48
51
  preset,
49
52
  theme,
50
53
  ui,
51
54
  assets,
55
+ expert,
52
56
  });
53
57
 
54
58
  simulator.mountRaceCanvas(canvasRoot, {
55
59
  includeRaceDataPanel: true,
56
60
  includeTimingTower: true,
57
- includeTelemetrySectorBanner: true,
58
61
  timingTowerVerticalFit: 'scroll',
59
62
  });
60
63
  await simulator.start();
@@ -75,11 +78,335 @@ simulator.mountTelemetryLapTimes(lapTimesRoot);
75
78
  simulator.mountTelemetrySectorTimes(sectorTimesRoot);
76
79
  simulator.mountRaceTelemetryDrawer(raceWorkbenchRoot, {
77
80
  timingTowerVerticalFit: 'expand-race-view',
81
+ raceDataTelemetryDetail: true,
78
82
  });
79
83
  simulator.mountCarDriverOverview(overviewRoot);
80
84
  simulator.mountRaceDataPanel(raceDataRoot);
81
85
  ```
82
86
 
87
+ ## Expert Environment Contract
88
+
89
+ Headless training code imports the browser-free environment subpath:
90
+
91
+ ```js
92
+ import { createPaddockEnvironment } from '@inventure71/paddockjs/environment';
93
+
94
+ const env = createPaddockEnvironment({
95
+ drivers,
96
+ entries,
97
+ controlledDrivers: ['budget'],
98
+ seed: 71,
99
+ trackSeed: 2026,
100
+ totalLaps: 3,
101
+ frameSkip: 2,
102
+ rules: {
103
+ ruleset: 'custom',
104
+ standingStart: false,
105
+ modules: {
106
+ penalties: {
107
+ trackLimits: { strictness: 0.25 },
108
+ },
109
+ },
110
+ },
111
+ scenario: {
112
+ participants: 'all',
113
+ nonControlled: 'ai',
114
+ },
115
+ });
116
+
117
+ let result = env.reset();
118
+ result = env.step({
119
+ budget: { steering: 0, throttle: 1, brake: 0 },
120
+ });
121
+ ```
122
+
123
+ `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.
124
+ `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
+
126
+ ```js
127
+ rules: {
128
+ ruleset: 'fia2025',
129
+ modules: {
130
+ pitStops: {
131
+ enabled: true,
132
+ pitLaneSpeedLimitKph: 80,
133
+ maxConcurrentPitLaneCars: 3,
134
+ minimumPitLaneGapMeters: 20,
135
+ tirePitRequestThresholdPercent: 50,
136
+ tirePitCommitThresholdPercent: 30,
137
+ },
138
+ penalties: {
139
+ trackLimits: { strictness: 0.8 },
140
+ collision: { strictness: 0.5, consequences: [{ type: 'time', seconds: 5 }] },
141
+ tireRequirement: { strictness: 1, consequences: [{ type: 'time', seconds: 10 }] },
142
+ pitLaneSpeeding: { strictness: 1, speedLimitKph: 80 },
143
+ },
144
+ },
145
+ }
146
+ ```
147
+
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.
149
+
150
+ Scenario support:
151
+
152
+ ```js
153
+ scenario: {
154
+ participants: 'all' | 'controlled-only' | ['budget', 'alpha'],
155
+ nonControlled: 'ai',
156
+ preset: 'cornering' | 'off-track-recovery' | 'overtaking-pack' | 'pit-entry',
157
+ placements: {
158
+ budget: {
159
+ distanceMeters: 420,
160
+ offsetMeters: 16,
161
+ speedKph: 65,
162
+ headingErrorRadians: 0.4,
163
+ },
164
+ },
165
+ traffic: [
166
+ {
167
+ driverId: 'alpha',
168
+ relativeTo: 'budget',
169
+ deltaDistanceMeters: 24,
170
+ offsetMeters: -1.5,
171
+ speedKph: 68,
172
+ headingErrorRadians: 0,
173
+ },
174
+ ],
175
+ }
176
+ ```
177
+
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.
179
+
180
+ Actions use normalized low-level controls:
181
+
182
+ ```js
183
+ {
184
+ budget: {
185
+ steering: -1, // full left
186
+ throttle: 1,
187
+ brake: 0,
188
+ },
189
+ }
190
+ ```
191
+
192
+ `steering` maps to the simulator's internal steering limit. `throttle` and `brake` are clamped from `0` to `1`.
193
+
194
+ 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
+
196
+ 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
+
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.
199
+
200
+ The environment exposes specs for external training code:
201
+
202
+ ```js
203
+ const actionSpec = env.getActionSpec();
204
+ const observationSpec = env.getObservationSpec();
205
+ ```
206
+
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]`.
208
+
209
+ `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
+
211
+ - forward progress in meters from `state.snapshot.cars[].distanceMeters`
212
+ - on-track speed in `current.object.self.speedKph`
213
+ - off-track penalty from `current.object.self.onTrack`
214
+ - collision penalty from global/per-driver step events
215
+ - steering and brake smoothness penalties from the submitted normalized action
216
+
217
+ Weights are explicit and overridable:
218
+
219
+ ```js
220
+ const reward = createProgressReward({
221
+ weights: {
222
+ progress: 1,
223
+ speed: 0.01,
224
+ offTrack: -2,
225
+ collision: -10,
226
+ steering: -0.03,
227
+ brake: -0.02,
228
+ },
229
+ });
230
+ ```
231
+
232
+ Custom reward functions receive the same context:
233
+
234
+ ```js
235
+ reward({ driverId, previous, current, action, events, state }) {
236
+ return current.object.self.speedKph / 100;
237
+ }
238
+ ```
239
+
240
+ If no reward callback is provided, `result.reward` is `null`. PaddockJS does not infer, select, or tune rewards.
241
+
242
+ Neutral rollout recording is available for external training loops:
243
+
244
+ ```js
245
+ import { createRolloutRecorder } from '@inventure71/paddockjs/environment';
246
+
247
+ const recorder = createRolloutRecorder();
248
+ const previous = env.reset();
249
+ const action = { budget: { steering: 0, throttle: 1, brake: 0 } };
250
+ const next = env.step(action);
251
+ recorder.recordStep(previous, action, next);
252
+ ```
253
+
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.
255
+
256
+ Deterministic evaluation helpers run fixed seeds/scenarios and report environment quality metrics:
257
+
258
+ ```js
259
+ import { runEnvironmentEvaluation } from '@inventure71/paddockjs/environment';
260
+
261
+ const report = runEnvironmentEvaluation({
262
+ baseOptions: { drivers, entries, controlledDrivers: ['budget'] },
263
+ policy(observation) {
264
+ return myPolicy.predict(observation);
265
+ },
266
+ });
267
+ ```
268
+
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.
270
+
271
+ `step(actions)` returns a JavaScript object designed for environment loops:
272
+
273
+ ```js
274
+ {
275
+ observation: {
276
+ budget: {
277
+ object: {
278
+ self: {
279
+ speedKph,
280
+ speedMetersPerSecond,
281
+ headingRadians,
282
+ steeringAngleRadians,
283
+ throttle,
284
+ brake,
285
+ lap,
286
+ completedLaps,
287
+ lapProgressMeters,
288
+ trackOffsetMeters,
289
+ trackHeadingErrorRadians,
290
+ onTrack, // true for track, kerb, and legal pit-lane/box surfaces
291
+ surface,
292
+ inPitLane,
293
+ pitLanePart,
294
+ pitBoxId,
295
+ tireEnergy,
296
+ pitIntent,
297
+ pitStopStatus,
298
+ pitStopPhase,
299
+ pitStopServiceRemainingSeconds,
300
+ pitStopPenaltyServiceRemainingSeconds,
301
+ pitStopsCompleted,
302
+ },
303
+ race: {
304
+ position,
305
+ totalCars,
306
+ raceMode,
307
+ totalLaps,
308
+ },
309
+ rays,
310
+ nearbyCars,
311
+ events,
312
+ },
313
+ vector,
314
+ schema,
315
+ events,
316
+ },
317
+ },
318
+ reward,
319
+ terminated,
320
+ truncated,
321
+ done,
322
+ events,
323
+ state: {
324
+ snapshot,
325
+ },
326
+ info: {
327
+ step,
328
+ elapsedSeconds,
329
+ seed,
330
+ trackSeed,
331
+ controlledDrivers,
332
+ actionErrors,
333
+ endReason,
334
+ },
335
+ }
336
+ ```
337
+
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.
339
+
340
+ Default rays use a compact center-origin set with forward, side, and rear awareness:
341
+
342
+ ```js
343
+ [-135, -60, -20, 0, 20, 60, 135, 180]
344
+ ```
345
+
346
+ Track distances are sampled against the actual track model. When the ray starts inside the track border, `track.kind: 'exit'` means the distance is where the ray leaves the valid road. When the ray starts outside the track border, `track.kind: 'entry'` means the distance is where the ray re-enters the valid road. If no track transition is visible within the ray length, `track.hit` is `false`, `track.distanceMeters` equals `lengthMeters`, and `track.kind` is `null`. Car hits require the ray to intersect the other car's footprint and also return max distance when no car is visible:
347
+
348
+ ```js
349
+ {
350
+ angleDegrees,
351
+ angleRadians,
352
+ lengthMeters,
353
+ track: {
354
+ hit,
355
+ distanceMeters,
356
+ kind, // 'exit' | 'entry' | null
357
+ },
358
+ car: {
359
+ hit,
360
+ distanceMeters,
361
+ driverId,
362
+ relativeSpeedKph,
363
+ },
364
+ }
365
+ ```
366
+
367
+ Nearby-car observations are car-relative:
368
+
369
+ ```js
370
+ {
371
+ id,
372
+ relativeForwardMeters,
373
+ relativeRightMeters,
374
+ relativeDistanceMeters,
375
+ relativeSpeedKph,
376
+ relativeHeadingRadians,
377
+ ahead,
378
+ sameLap,
379
+ }
380
+ ```
381
+
382
+ If no `reward` callback is provided, `result.reward` is `null`. If provided, rewards are returned by controlled driver ID.
383
+
384
+ Browser expert mode is opt-in through the browser mount API:
385
+
386
+ ```js
387
+ const simulator = await mountF1Simulator(root, {
388
+ drivers,
389
+ entries,
390
+ expert: {
391
+ enabled: true,
392
+ controlledDrivers: ['budget'],
393
+ frameSkip: 4,
394
+ visualizeSensors: {
395
+ rays: true,
396
+ },
397
+ },
398
+ });
399
+
400
+ simulator.expert.reset();
401
+ simulator.expert.step({
402
+ budget: { steering: 0, throttle: 1, brake: 0 },
403
+ });
404
+ ```
405
+
406
+ 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
+
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.
409
+
83
410
  ## Lap Telemetry Snapshot
84
411
 
85
412
  Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timing, plus sector performance classes used by package UI:
@@ -92,6 +419,8 @@ Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timin
92
419
  currentSectorElapsed,
93
420
  currentSectorProgress,
94
421
  currentSectors,
422
+ sectorProgress,
423
+ liveSectors,
95
424
  lastLapTime,
96
425
  bestLapTime,
97
426
  lastSectors,
@@ -105,13 +434,17 @@ Each `car.lapTelemetry` snapshot includes current/last/best lap and sector timin
105
434
  }
106
435
  ```
107
436
 
108
- `overall-best` means fastest sector time currently known across the field, `personal-best` means that driver's own fastest non-overall sector, and `slower` marks a completed sector that is slower than the driver's personal best. Missing or in-progress sector values are `null`.
437
+ `currentSectors` contains completed split times for the current lap only, so the active sector remains `null` until its boundary is crossed and sectors after the active sector are cleared. `sectorProgress` is a `0..1` progress array for the live sector-map surface: recorded completed sectors before the active sector are `1`, the active sector is live, and missing or future sectors are `0`. `liveSectors` mirrors recorded completed current-lap sector times before the active sector and fills only the active sector with its live elapsed time. It does not infer earlier sector values purely from the current sector number, and it never displays future-sector values for the current lap, so skipped, missing, or stale split data stays blank instead of creating stale S1/S2 readouts. `overall-best` means fastest sector time currently known across the field, `personal-best` means that driver's own fastest non-overall sector, and `slower` marks a completed sector that is slower than the driver's personal best. Missing or future sector values are `null`.
109
438
 
110
439
  ## Required Options
111
440
 
112
- `drivers` is required and must be a non-empty array.
441
+ `drivers` is required and must be a non-empty array. Driver `id` values must be unique.
113
442
 
114
- `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.
443
+ `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
+
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.
446
+
447
+ `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.
115
448
 
116
449
  Each driver must have:
117
450
 
@@ -193,12 +526,17 @@ Entries are optional. If omitted, defaults are used.
193
526
  name: 'Ledger Racing',
194
527
  color: '#00ff84',
195
528
  icon: 'LR',
529
+ pitCrew: {
530
+ speed: 0.72,
531
+ consistency: 0.81,
532
+ reliability: 0.9,
533
+ },
196
534
  },
197
535
  }
198
536
  ```
199
537
 
200
- Entries match drivers by `driverId`.
201
- The car/driver overview primarily renders the existing driver and vehicle rating components from `driver` and `vehicle`. `team` is optional team-level metadata for race identity and future pit behavior; `color` defaults to the driver/car color, and `icon` defaults from the team name or timing code. The timing tower uses the team icon in the car/team column. `driver.customFields`, `vehicle.customFields`, and top-level driver `customFields` are accepted as extra metadata after those defined components.
538
+ Entries match drivers by `driverId`. Entry `driverId` values must be unique. `driverNumber` is optional; if omitted, PaddockJS falls back to stable grid order. When `driverNumber` is provided, values must be unique.
539
+ The car/driver overview primarily renders the existing driver and vehicle rating components from `driver` and `vehicle`. `team` is optional team-level metadata for race identity and pit behavior; `color` defaults to the driver/car color, and `icon` defaults from the team name or timing code. `team.pitCrew` may define `speed`, `consistency`, and `reliability` from `0` to `1`; these values affect optional pit-stop variability when `rules.modules.pitStops.variability.enabled` is true. The timing tower uses the team icon in the car/team column. `driver.customFields`, `vehicle.customFields`, and top-level driver `customFields` are accepted as extra metadata after those defined components.
202
540
 
203
541
  ## Rating Rules
204
542
 
@@ -239,7 +577,11 @@ Optional lifecycle callbacks:
239
577
  }
240
578
  ```
241
579
 
242
- `onRaceEvent` receives simulation events such as `contact`, `safety-car`, `green-flag`, `start-lights-out`, and `race-finish`. Host callback errors are caught; if `onError` exists, it receives `{ callback: name }` context for callback failures.
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.
581
+
582
+ 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
+
584
+ Penalty status values are `issued`, `served`, `applied`, and `cancelled`. Time, position-drop, grid-drop, and disqualification consequences are immediate `applied` penalties. Drive-through and stop-go consequences are service penalties: they start as `issued`, can be completed with `servePenalty(penaltyId)`, and convert to applied time if unserved when final classification is calculated. Pit stops also serve eligible penalties before tire work starts: applied time penalties add their seconds as a hold, stop-go penalties add their configured service seconds, and drive-through penalties are marked served by the pit-lane traversal without extra stationary hold time.
243
585
 
244
586
  ## Asset Overrides
245
587
 
@@ -285,7 +627,7 @@ Current UI options:
285
627
  ```js
286
628
  ui: {
287
629
  layoutPreset: 'standard',
288
- cameraControls: 'embedded',
630
+ cameraControls: 'external',
289
631
  showFps: true,
290
632
  showTimingTower: true,
291
633
  showTelemetry: true,
@@ -302,12 +644,13 @@ ui: {
302
644
  enabled: ['project', 'radio'],
303
645
  },
304
646
  raceDataBannerSize: 'custom',
647
+ raceDataTelemetryDetail: false,
305
648
  timingTowerVerticalFit: 'expand-race-view',
306
649
  }
307
650
  ```
308
651
 
309
- - `layoutPreset`: `'standard'` or `'left-tower-overlay'`. The overlay preset creates a left broadcast gutter inside the race canvas, places the timing tower in that gutter at the same width as the default timing-board column, and keeps camera controls and camera framing in the remaining race-view area. In the combined shell, the project/radio lower-third stays inside the race window and can render over the timing sidebar.
310
- - `cameraControls`: `'embedded'`, `'external'`, or `false`. Embedded controls render inside the race canvas. External controls are mounted with `mountCameraControls(root)`. `false` leaves camera controls unrendered, though callers can still drive selection through controller methods.
652
+ - `layoutPreset`: `'standard'` or `'left-tower-overlay'`. The overlay preset creates a left broadcast gutter inside the race canvas, places the timing tower in that gutter at the same width as the default timing-board column, and frames the camera around the remaining race-view area. Camera controls are external by default. In the combined shell, the project/radio lower-third stays inside the race window and can render over the timing sidebar.
653
+ - `cameraControls`: `'embedded'`, `'external'`, or `false`. The default is external so camera controls do not cover the race view. Embedded controls render inside the race canvas only when explicitly requested. External controls are mounted with `mountCameraControls(root)` or included in package-owned workbench templates. The generated controls include mode buttons, zoom buttons, and a `Mute banners` toggle that temporarily disables project/radio lower-thirds while active. A browser-playback speed button is optional for normal camera controls through `ui.simulationSpeedControl: true`; the complete race workbench enables it by default and cycles `1x`, `2x`, `3x`, `4x`, `5x`, `10x`, then back to `1x`. `false` leaves camera controls unrendered, though callers can still drive selection through controller methods.
311
654
  - `showFps`: controls whether the race canvas renders the FPS readout.
312
655
  - `showRaceDataPanel`: controls whether the precombined shell includes the project/radio lower-third inside the race window.
313
656
  - `showTimingTower`, `showTelemetry`: reserved component visibility flags for host layout decisions.
@@ -315,7 +658,11 @@ ui: {
315
658
  - `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`.
316
659
  - `raceDataBanners.initial`: `'project'`, `'radio'`, or `'hidden'`. This controls which lower-third appears first in the precombined shell.
317
660
  - `raceDataBanners.enabled`: array containing `'project'` and/or `'radio'`. Disabled banner types never appear, including after driver selection.
661
+ - Project/radio lower-thirds include a package-owned top-right close button. Clicking it hides the current pill before its scheduled timeout and waits for the next normal driver selection or radio schedule before showing another pill. The camera-control `Mute banners` toggle is off by default and suppresses both project and radio lower-thirds until toggled off again; steward penalty banners are not part of this mute state.
318
662
  - `raceDataBannerSize`: `'custom'` preserves the default lower-third geometry and exposes package CSS variables for host tuning. `'auto'` uses the race space to the right of the timing board when there is enough room and falls back to full lower-third overlap when there is not.
663
+ - `raceDataTelemetryDetail`: when `true`, the project lower-third includes compact S1/S2/S3 sector progress and timing readouts and stays visible until the user dismisses it, banners are muted, or another banner state replaces it. Radio mode keeps the normal quote layout and schedule. The separate `mountTelemetrySectorBanner()` component remains available for hosts that explicitly want an independent sector banner.
664
+ - `penaltyBanners`: when `true`, the race view shows a top steward message for track-limit warnings and new steward penalty decisions. Time penalties show a large `+10s` style chip in the left block, with the affected car and rule/reason beside it. Warning-only messages use warning colors and remain separate from penalty decisions. It does not replace the project/radio lower-third.
665
+ - `timingPenaltyBadges`: when `true`, timing rows for penalized drivers show a red `!` badge with an accessible penalty label. Warning-only events do not count as penalties and do not show the badge.
319
666
  - `timingTowerVerticalFit`: `'expand-race-view'` lets the combined race window grow to contain the timing tower. `'scroll'` keeps the race window height and scrolls the timing list inside the cropped tower. The same values can be passed to `mountRaceCanvas(root, { includeTimingTower: true, timingTowerVerticalFit })` for an embedded composable timing tower.
320
667
 
321
668
  No UI option exists for raw timing-tower width, max width, or horizontal ratio. The timing tower is capped by the package CSS variable `--timing-board-max-width` because very wide timing boards read poorly. Host pages can scale the whole simulator by changing the mount container, but package-owned layout presets keep their internal proportions inside PaddockJS. For standalone timing towers, give the mount root a fixed height when a fixed vertical footprint is needed; the package keeps the frame inside that height and scrolls only the timing entries. Narrow hosts are handled internally: side-gutter timing towers become stacked/full-width, embedded timing towers stop behaving like desktop side overlays, and the camera safe area stops reserving a left gutter when the measured timing board is effectively full-width.
@@ -354,13 +701,33 @@ After every car completes `totalLaps`, `getSnapshot()` returns:
354
701
  finishedAt: 123.4,
355
702
  winner: { id, code, name, rank, finished },
356
703
  classification: [
357
- { id, code, timingCode, name, rank, lap, lapsCompleted, distanceMeters, gapMeters, gapSeconds, intervalSeconds, finished, finishTime },
704
+ { id, code, timingCode, name, rank, lap, lapsCompleted, distanceMeters, gapMeters, gapSeconds, intervalSeconds, gapLaps, intervalLaps, finished, finishTime, penaltySeconds, adjustedFinishTime, positionDrop, disqualified },
358
705
  ],
359
706
  },
360
707
  }
361
708
  ```
362
709
 
363
- Cars also include `team`, `speedKph`, `distanceMeters`, `gapAheadMeters`, `gapAheadSeconds`, `intervalAheadSeconds`, `leaderGapSeconds`, `finished`, `finishTime`, `classifiedRank`, and `lapTelemetry`. `gapAheadSeconds` and `intervalAheadSeconds` are the interval to the car directly ahead. `leaderGapSeconds` is the cumulative gap to P1. The first car to finish sets `raceControl.winner` and receives a `car-finish` event, but the race keeps running until all cars finish. After final classification, the field continues under safety-car behavior.
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.
711
+
712
+ 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
+
714
+ Car snapshots expose `surface`, `signedOffset`, `crossTrackError`, `inPitLane`, `pitLanePart`, `pitBoxId`, and `pitLaneCrossTrackError` so hosts can distinguish main-circuit running from pit entry, fast lane, working lane, pit exit, and service-box states without recalculating geometry. `surface` is the worst current wheel/contact-patch surface, not only the center point. Each car also exposes `wheels`, with one entry per contact patch:
715
+
716
+ ```js
717
+ {
718
+ id: 'front-left',
719
+ x,
720
+ y,
721
+ signedOffset,
722
+ crossTrackError,
723
+ surface,
724
+ onTrack,
725
+ inPitLane,
726
+ fullyOutsideWhiteLine,
727
+ }
728
+ ```
729
+
730
+ Use `wheels` when a host needs exact debug overlays, per-wheel surface labels, or track-limit state. A car is track-limit illegal only when all four wheel contact patches are fully outside the same white line; a mixed state such as one wheel on gravel and one wheel still inside the white line reports the worse physics surface but does not count as a track-limit violation.
364
731
 
365
732
  ## Track And Lap Telemetry Snapshot
366
733
 
@@ -374,6 +741,21 @@ Every built track is automatically divided into three equal sectors. `snapshot.t
374
741
  ]
375
742
  ```
376
743
 
744
+ 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
+
746
+ Every built track also exposes `snapshot.track.pitLane`. The pit lane is deterministic for the track seed and contains:
747
+
748
+ - `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
+ - `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.
750
+ - `mainLane`: straight pit-lane fast-lane start/end points, heading, and length.
751
+ - `workingLane`: parallel pit box lane start/end points, offset, width, and centerline points.
752
+ - `exit`: connector points from the pit lane back to the racing surface after the start line, plus the true track `edgePoint`, an overlapping lane-facing `trackConnectPoint` on the track surface, and a procedural `roadCenterline` that is tangent to the straight pit lane at pit-lane end and tangent to the main track at merge.
753
+ - `teams`: team pit groups with id, name, color, index, the two assigned garage box ids, and one shared service area id.
754
+ - `serviceAreas`: 10 team service areas, one per team, each with center, queue point, corners, queue corners, team index, and optional `teamId`, `teamName`, and `teamColor`.
755
+ - `boxes`: 20 unused garage boxes as 10 team pairs, each with center, lane target, corners, team index, box index, and optional `teamId`, `teamName`, and `teamColor`.
756
+
757
+ Pit-lane road and box states are legal drivable surfaces. Track state may report `surface: 'pit-entry'`, `'pit-lane'`, `'pit-exit'`, or `'pit-box'` with `inPitLane: true`; `crossTrackError` remains the distance from the main race track for compatibility, while pit-specific offsets are exposed as pit-lane fields on the internal state.
758
+
377
759
  Each car exposes `lapTelemetry`:
378
760
 
379
761
  ```js
@@ -384,6 +766,8 @@ Each car exposes `lapTelemetry`:
384
766
  currentSectorElapsed,
385
767
  currentSectorProgress,
386
768
  currentSectors: [s1Time, s2Time, s3Time],
769
+ sectorProgress: [s1Progress, s2Progress, s3Progress],
770
+ liveSectors: [s1LiveTime, s2LiveTime, s3LiveTime],
387
771
  lastLapTime,
388
772
  bestLapTime,
389
773
  lastSectors: [s1Time, s2Time, s3Time],
@@ -392,7 +776,7 @@ Each car exposes `lapTelemetry`:
392
776
  }
393
777
  ```
394
778
 
395
- Times are seconds or `null` when no timing exists yet. `currentSectorProgress` is `0..1` within the active sector.
779
+ Times are seconds or `null` when no timing exists yet. `currentSectorProgress` is `0..1` within the active sector. `sectorProgress` is the live fill state for the current lap: completed sectors before the active sector are filled only when a real split was recorded, the active sector progresses live, and missing/future sectors stay empty. `currentSectors` contains only completed current-lap split times before the active sector, while `liveSectors` combines those recorded completed splits with the active sector's elapsed time and clears any stale future-sector values.
396
780
 
397
781
  ## Unit Conversion
398
782
 
@@ -401,9 +785,10 @@ The simulation keeps its internal physics in simulator units. Public speed and d
401
785
  - `simUnitsToMeters(simUnits)`
402
786
  - `metersToSimUnits(meters)`
403
787
  - `simSpeedToKph(simUnitsPerSecond)`
788
+ - `simSpeedToMetersPerSecond(simUnitsPerSecond)`
404
789
  - `kphToSimSpeed(kph)`
405
790
 
406
- The current calibrated speed scale maps `VEHICLE_LIMITS.maxSpeed` to an F1-like `330 km/h`. Rendered car sprite size remains a visual scale and is intentionally larger than physical car length for readability.
791
+ All public timer, lap-time, sector-time, gap-time, penalty-time, and service-countdown values are seconds. Public distance values with a `Meters` suffix are meters. Internal cumulative fields such as `raceDistance` stay in simulator units and must be converted before being presented as physical distance. The current calibrated speed scale maps `VEHICLE_LIMITS.maxSpeed` to an F1-like `330 km/h`. Rendered car sprite size remains a visual scale and is intentionally larger than physical car length for readability.
407
792
 
408
793
  ## Returned Controller
409
794
 
@@ -414,27 +799,34 @@ const simulator = await mountF1Simulator(root, options);
414
799
  Controller methods:
415
800
 
416
801
  - `destroy()`: removes listeners, destroys PixiJS runtime, clears the host root.
417
- - `restart(nextOptions)`: restarts the simulation with merged options.
802
+ - `restart(nextOptions)`: restarts the simulation with merged non-asset options. Use `destroy()` and mount a new simulator to change bundled or host-provided asset URLs.
418
803
  - `selectDriver(driverId)`: selects and focuses a driver.
419
804
  - `setSafetyCarDeployed(deployed)`: toggles safety car state.
805
+ - `setRedFlagDeployed(deployed)`: freezes or releases the race under red-flag race control.
806
+ - `setPitLaneOpen(open)`: opens or closes the pit lane for new pending pit entries.
420
807
  - `callSafetyCar()`: deploys the safety car.
421
808
  - `clearSafetyCar()`: releases the safety car.
422
809
  - `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.
811
+ - `getPitIntent(driverId)`: reads the current pit intent.
812
+ - `getPitTargetCompound(driverId)`: reads the current pit target tire.
813
+ - `servePenalty(penaltyId)`: marks an issued drive-through or stop-go penalty as served.
814
+ - `cancelPenalty(penaltyId)`: cancels a penalty so it no longer affects service, timing, grid, or classification.
423
815
  - `getSnapshot()`: returns the latest simulation snapshot.
424
816
 
425
817
  Composable controllers additionally expose:
426
818
 
427
819
  - `mountRaceControls(root)`: renders the top control/header component.
428
- - `mountCameraControls(root)`: renders package-owned camera mode and zoom controls outside the race canvas.
820
+ - `mountCameraControls(root)`: renders package-owned camera mode, zoom, and project/radio banner mute controls outside the race canvas.
429
821
  - `mountSafetyCarControl(root)`: renders a package-owned safety-car button that binds to the same race-control state as other safety buttons.
430
- - `mountTimingTower(root)`: renders the timing tower component.
431
- - `mountRaceCanvas(root, { includeRaceDataPanel, includeTimingTower, timingTowerVerticalFit })`: renders the PixiJS canvas host, optional FPS, start lights, and optionally embedded camera controls. Pass `includeRaceDataPanel: true` to place the project/radio lower-third inside the race window so it shares race-canvas clipping and layering. Pass `includeTimingTower: true` to place the timing tower inside the race canvas; `timingTowerVerticalFit: 'expand-race-view'` grows the canvas to the tower height, while `'scroll'` keeps the canvas height and scrolls timing rows inside the tower frame. This is required before `start()`.
432
- - `mountTelemetryPanel(root, { includeOverview })`: renders the package-owned telemetry stack template. The stack is only a composition of detached telemetry surfaces; it includes the car/driver overview by default unless `includeOverview: false` is passed or `ui.telemetryIncludesOverview` is `false`.
822
+ - `mountTimingTower(root)`: renders the timing tower component. The tower includes one hidden race-control status banner slot above the timing rows; `raceControl.mode: 'safety-car'` shows the yellow safety-car status, and `raceControl.mode: 'red-flag'` shows the red red-flag status.
823
+ - `mountRaceCanvas(root, { includeRaceDataPanel, includeTimingTower, includeTelemetrySectorBanner, timingTowerVerticalFit })`: renders the PixiJS canvas host, optional FPS, start lights, and the top steward message. Camera controls are external by default and render inside the race canvas only when `ui.cameraControls: 'embedded'` is explicitly requested. Pass `includeRaceDataPanel: true` to place the project/radio lower-third inside the race window so it shares race-canvas clipping and layering. Pass `includeTelemetrySectorBanner: true` only when the host intentionally wants the independent sector lower-third in addition to the project/radio banner. Pass `includeTimingTower: true` to place the timing tower inside the race canvas; `timingTowerVerticalFit: 'expand-race-view'` grows the canvas to the tower height, while `'scroll'` keeps the canvas height and scrolls timing rows inside the tower frame. This is required before `start()`.
824
+ - `mountTelemetryPanel(root, { includeOverview })`: renders the package-owned telemetry stack template. The stack is only a composition of detached telemetry surfaces, owns vertical scrolling when its host is shorter than its contents, and includes the car/driver overview by default unless `includeOverview: false` is passed or `ui.telemetryIncludesOverview` is `false`.
433
825
  - `mountTelemetryCore(root)`: renders selected-car scalar telemetry only.
434
826
  - `mountTelemetrySectors(root)`: renders the live sector progress graph only.
435
827
  - `mountTelemetryLapTimes(root)`: renders current, last, and best lap timing only.
436
828
  - `mountTelemetrySectorTimes(root)`: renders last and best sector timing only.
437
- - `mountRaceTelemetryDrawer(root, { timingTowerVerticalFit, drawerInitiallyOpen })`: renders a template that combines race canvas, embedded timing tower, lower-third banner, and a right-side telemetry drawer. The drawer uses the detached telemetry components and takes layout space from the race window when opened.
829
+ - `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.
438
830
  - `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.
439
831
  - `mountRaceDataPanel(root)`: renders the project/race-data lower-third as a separate component for hosts that intentionally want it outside the race canvas.
440
832
  - `start()`: initializes PixiJS, binds mounted controls, and starts the simulation loop.
@@ -151,10 +151,11 @@ Team metadata is copied onto the same normalized driver object. `team.color` def
151
151
  simUnitsToMeters(simUnits);
152
152
  metersToSimUnits(meters);
153
153
  simSpeedToKph(simUnitsPerSecond);
154
+ simSpeedToMetersPerSecond(simUnitsPerSecond);
154
155
  kphToSimSpeed(kph);
155
156
  ```
156
157
 
157
- The physics engine still works in simulator units. Public display fields such as `speedKph`, `distanceMeters`, and `gapMeters` use the conversion helpers. The current scale maps the simulator's maximum speed to `330 km/h`; rendered car sprite dimensions are deliberately larger than physical F1 dimensions for readability and are tracked separately from the physics distance scale.
158
+ The physics engine still works in simulator units. Public display fields such as `speedKph`, `distanceMeters`, and `gapMeters` use the conversion helpers. Public timer, lap-time, sector-time, gap-time, penalty-time, and service-countdown values are seconds. The current scale maps the simulator's maximum speed to `330 km/h`; rendered car sprite dimensions are deliberately larger than physical F1 dimensions for readability and are tracked separately from the physics distance scale.
158
159
 
159
160
  ## Runtime Flow
160
161
 
package/docs/index.md CHANGED
@@ -8,6 +8,7 @@ Use these docs as the package source of truth:
8
8
  - [Rules](rules.md): race-control behavior, DRS, safety car, starts, ordering, contact handling, and simulation rules.
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
+ - [Bring Your Own Model](training.md): environment contract, policy convention, and visual playback loop.
11
12
  - [Architecture](architecture.md): module ownership and data/control flow.
12
13
  - [Data Model Notes](data_model.md): lower-level simulator data model notes kept with package docs.
13
14
  - [Learnings](learnings.md): implementation lessons and layout verification rules learned from simulator integration issues.
package/docs/learnings.md CHANGED
@@ -41,7 +41,7 @@ For visual layout changes, verify more than the happy path screenshot:
41
41
  - Desktop, tablet, and narrow mobile widths.
42
42
  - Standard and overlay presets.
43
43
  - `scrollWidth <= clientWidth` for shell, canvas panel, timing tower, timing list, timing rows, column header, race-data panel, telemetry, car overview, camera controls, and topbar controls.
44
- - Overlay safe-area geometry: camera controls must start to the right of the overlay tower, while race-data banners are allowed to overlay the tower but must remain centered and clipped inside the race window.
44
+ - Overlay safe-area geometry: external camera controls must stay outside the race view, while race-data banners are allowed to overlay the tower but must remain centered and clipped inside the race window.
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.