@jgengine/shell 0.13.0 → 0.15.0-next.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 (124) hide show
  1. package/CHANGELOG.md +529 -1
  2. package/dist/CombatPresentation.js +2 -1
  3. package/dist/GamePlayerShell.js +25 -5
  4. package/dist/Shell3dPresentation.d.ts +6 -6
  5. package/dist/Shell3dPresentation.js +4 -4
  6. package/dist/camera/ControllerCameraShake.d.ts +31 -0
  7. package/dist/camera/ControllerCameraShake.js +34 -0
  8. package/dist/camera/GameCameraRig.js +10 -5
  9. package/dist/camera/GameOrbitCamera.js +1 -10
  10. package/dist/camera/cameraRigs.js +16 -4
  11. package/dist/camera/index.d.ts +1 -0
  12. package/dist/camera/index.js +1 -0
  13. package/dist/camera/rigMath.d.ts +2 -0
  14. package/dist/camera/rigMath.js +7 -0
  15. package/dist/devtools/CollisionDebugWorld.js +15 -1
  16. package/dist/devtools/DevtoolsOverlay.js +2 -0
  17. package/dist/devtools/collisionDebugMath.d.ts +8 -0
  18. package/dist/devtools/collisionDebugMath.js +14 -5
  19. package/dist/drivers/FrameDriver.d.ts +4 -1
  20. package/dist/drivers/FrameDriver.js +5 -3
  21. package/dist/environment/DayNightSky.d.ts +33 -0
  22. package/dist/environment/DayNightSky.js +51 -0
  23. package/dist/environment/Daylight.d.ts +3 -1
  24. package/dist/environment/Daylight.js +7 -3
  25. package/dist/environment/EnvironmentScene.js +6 -6
  26. package/dist/environment/RoadJunctions.d.ts +8 -4
  27. package/dist/environment/RoadJunctions.js +16 -15
  28. package/dist/environment/RoadRibbons.d.ts +42 -6
  29. package/dist/environment/RoadRibbons.js +150 -27
  30. package/dist/environment/VolumetricClouds.js +3 -3
  31. package/dist/environment/index.d.ts +1 -0
  32. package/dist/environment/index.js +1 -0
  33. package/dist/gameKit.d.ts +7 -4
  34. package/dist/gameKit.js +8 -5
  35. package/dist/inputSink.js +14 -0
  36. package/dist/materialOverride.js +30 -0
  37. package/dist/postfx/PostProcessing.d.ts +3 -2
  38. package/dist/postfx/PostProcessing.js +38 -5
  39. package/dist/postfx/ScreenEffectsOverlay.d.ts +32 -0
  40. package/dist/postfx/ScreenEffectsOverlay.js +70 -0
  41. package/dist/postfx/postfxOverlay.d.ts +24 -0
  42. package/dist/postfx/postfxOverlay.js +35 -0
  43. package/dist/presentationResolve.d.ts +3 -2
  44. package/dist/presentationResolve.js +11 -6
  45. package/dist/render/CascadedShadows.d.ts +13 -0
  46. package/dist/render/CascadedShadows.js +96 -0
  47. package/dist/render/EntityPreview.d.ts +53 -0
  48. package/dist/render/EntityPreview.js +23 -0
  49. package/dist/render/EnvironmentLighting.d.ts +23 -7
  50. package/dist/render/EnvironmentLighting.js +69 -10
  51. package/dist/render/PartMotion.js +4 -4
  52. package/dist/render/SceneLighting.js +7 -0
  53. package/dist/render/SceneModels.d.ts +37 -1
  54. package/dist/render/SceneModels.js +52 -10
  55. package/dist/render/measureBounds.js +4 -0
  56. package/dist/render/measureCollisionMesh.d.ts +26 -0
  57. package/dist/render/measureCollisionMesh.js +108 -0
  58. package/dist/render/modelLoad.d.ts +41 -5
  59. package/dist/render/modelLoad.js +85 -18
  60. package/dist/render/sceneCapture.d.ts +27 -0
  61. package/dist/render/sceneCapture.js +43 -0
  62. package/dist/render/useDisposable.d.ts +16 -0
  63. package/dist/render/useDisposable.js +24 -0
  64. package/dist/render/useModelAnimation.js +47 -4
  65. package/dist/scene/AuthoredScene.js +14 -24
  66. package/dist/scene/CityRenderer.js +103 -36
  67. package/dist/scene/GrassFieldRenderer.js +4 -1
  68. package/dist/scene/PoleLineRenderer.js +7 -16
  69. package/dist/settings/QuickControls.js +5 -3
  70. package/dist/settings/SettingsMenu.js +6 -3
  71. package/dist/settings/appliedSettings.d.ts +3 -1
  72. package/dist/settings/appliedSettings.js +17 -5
  73. package/dist/settings/settingsController.js +11 -5
  74. package/dist/shellMovement.d.ts +15 -1
  75. package/dist/shellMovement.js +15 -1
  76. package/dist/structures/GeneratedBuilding.js +4 -5
  77. package/dist/structures/TransformGizmo.js +6 -5
  78. package/dist/terrain/CarvedTerrain.js +3 -7
  79. package/dist/terrain/GrassField.js +2 -2
  80. package/dist/terrain/ProceduralGround.js +3 -5
  81. package/dist/terrain/grassMaterial.d.ts +8 -2
  82. package/dist/terrain/grassMaterial.js +24 -5
  83. package/dist/terrain/index.d.ts +1 -1
  84. package/dist/terrain/terrainDetailMaterial.js +11 -2
  85. package/dist/terrain/terrainMath.d.ts +38 -1
  86. package/dist/terrain/terrainMath.js +86 -64
  87. package/dist/touch/TouchControlsOverlay.d.ts +6 -2
  88. package/dist/touch/TouchControlsOverlay.js +136 -17
  89. package/dist/vfx/ParticleField.d.ts +32 -0
  90. package/dist/vfx/ParticleField.js +91 -0
  91. package/dist/vfx/WorldFloatingText.d.ts +32 -0
  92. package/dist/vfx/WorldFloatingText.js +32 -0
  93. package/dist/water/Ocean.js +3 -6
  94. package/dist/weather/LightningStrike.js +3 -9
  95. package/dist/weather/RainField.js +3 -7
  96. package/dist/weather/SnowField.js +3 -7
  97. package/dist/world/DataObjects.js +3 -6
  98. package/dist/world/GridWorldScene.js +3 -6
  99. package/dist/world/InstancedBodies.js +3 -6
  100. package/dist/world/InstancedJoints.js +2 -2
  101. package/dist/world/SpriteBatch.js +3 -6
  102. package/dist/world/WorldEntityFrames.d.ts +47 -0
  103. package/dist/world/WorldEntityFrames.js +75 -0
  104. package/dist/world/WorldHud.js +7 -5
  105. package/dist/world/WorldPings.d.ts +26 -0
  106. package/dist/world/WorldPings.js +68 -0
  107. package/dist/world/WorldScene.js +9 -4
  108. package/dist/world/pingPulse.d.ts +21 -0
  109. package/dist/world/pingPulse.js +17 -0
  110. package/package.json +5 -5
  111. package/skills/ce-handoff/SKILL.md +149 -0
  112. package/skills/game-design/SKILL.md +2 -0
  113. package/skills/jgengine/SKILL.md +7 -3
  114. package/skills/jgengine/api.md +17 -7
  115. package/skills/jgengine/capabilities.md +12 -0
  116. package/skills/jgengine/recipes/minimal-game.md +7 -4
  117. package/skills/jgengine-ui/SKILL.md +8 -3
  118. package/skills/jgengine-ui/api.md +732 -23
  119. package/skills/jgengine-ui/capabilities.md +371 -2
  120. package/skills/jgengine-ui/recipes/overhead-entity-frames.md +136 -0
  121. package/skills/jgengine-ui/reference.md +7 -4
  122. package/skills/jgengine-world/api.md +408 -12
  123. package/skills/jgengine-world/capabilities.md +127 -2
  124. package/skills/jgengine-world/reference.md +15 -1
@@ -234,9 +234,9 @@ from an external ECS/store snapshot; it needs no `GameProvider`, Three.js, or
234
234
  React Three Fiber. Follow [the portable minimap recipe](recipes/portable-minimap.md)
235
235
  instead of mirroring external entities into `createMarkerSet`.
236
236
 
237
- **Minimap** is already shipped, not hand-rolled: `Minimap` / `WorldMap` / `Compass` from `@jgengine/react/map` render a framed SVG minimap over a `MarkerSet` (`createMarkerSet`, `@jgengine/core/world/markers`) and optional `FogField`, projecting via `@jgengine/core/world/minimap` (`projectToMinimap`, `headingToBearing`, edge-clamp). A game feeds flat props (`markers`, `center: [x,z]`, `worldRadius`, `size`, `facingYaw`, `rotate`) and repopulates the marker set each HUD tick from live entities — no custom canvas painter. `MinimapPanel` (same module) composes zone-label + clock header slots and a `Compass` strip around a `Minimap` — pass `zoneLabel`/`clock` nodes (e.g. from `useGameClock().calendar`) and `showCompass`/`compassProps`; omit either header slot and it collapses, `showCompass={false}` drops the strip. **Baked terrain background**: to draw the authored terrain under the markers, pass `background={document.minimap?.background}` and `mapBounds={document.minimap?.bounds}` to `Minimap`/`WorldMap` (or `MinimapPanel`, which forwards both through) — the editor's `bake_minimap` action stores a top-down terrain PNG + world bounds on `EditorDocument.minimap` (see `jgengine-editor`), and runtime reads that stored PNG with no re-rasterization. Both are undefined until baked, so an unbaked game renders the plain map unchanged. Rolling a **custom** minimap around your own course/route content (not the full `Minimap` panel)? `MinimapChrome` is the headless chrome layer other games were re-drawing by hand: nest it inside your own `<svg>` for the ring `frame`, and pass `markers: MinimapChromeMarker[]` (`{ id, position, heading?, color?, radius?, glyph? }`) for edge-clamped dots that draw as a rotated arrow whenever a marker carries a `heading` (the compass-arrow / player-blip layer) — never hand-roll `clampToMinimapEdge` + a `<polygon>` again. **Nameplates**: `WorldNameplates` (`@jgengine/shell/world/WorldHud`) billboards a name + optional 78×6px HP bar over every nearby non-local entity — turn it on declaratively via `defineGame({ nameplates: { roles, maxDistance } })` (mirrors `worldHealthBars`) rather than mounting it by hand; skin every part via `className`/`data-*` slots (`nameplateClassName`, `nameClassName`, `barClassName`, `fillClassName`) or swap the whole markup with `renderNameplate`. **Selection rings**: `WorldObjectHighlights` (`@jgengine/shell/world/WorldHud`) draws a ground ring over every `ctx.scene.object.selection`-ed placed object — the object-layer counterpart to `WorldEntityBars`/`WorldNameplates`; mount it once in the game's scene (`color`/`radius`/`y` props, `radius` otherwise derived from the object's catalog `halfExtents`) instead of hand-rolling a selection highlight through `WorldOverlay` against external state. **Playing cards** are already shipped, not hand-rolled: `CardFace` / `StackedPile` / `CardStack` (with `Suit`, `Rank`, `PlayingCard`) from `@jgengine/react/cards` render a French-deck face (rank + suit pips, patterned back when `faceDown`) and an overlapping fan. `StackedPile` composes the existing drag system (`useDragLayer`/`DraggableCard`/`DropZone`) — pass a `layer` for drag and `dropId` for a drop target; `CardStack` binds a fan to a headless `CardPileState` zone (`@jgengine/core/cards/cardPile`). Never draw cards on a canvas or reinvent pointer handling. **Swing timer**: `swingTimerState(player, target, prevPeriod, prevTimer)` (`@jgengine/core/ui/swingTimer`) is the pure, parameter-in/next-state-out core for a melee swing bar (recovers period on the reset edge; hidden unless auto-attacking a live non-object target) — thread the returned `nextPeriod`/`nextTimer` back via refs, render a thin `Meter`.
237
+ **Minimap** is already shipped, not hand-rolled: `Minimap` / `WorldMap` / `Compass` from `@jgengine/react/map` render a framed SVG minimap over a `MarkerSet` (`createMarkerSet`, `@jgengine/core/world/markers`) and optional `FogField`, projecting via `@jgengine/core/world/minimap` (`projectToMinimap`, `headingToBearing`, edge-clamp). A game feeds flat props (`markers`, `center: [x,z]`, `worldRadius`, `size`, `facingYaw`, `rotate`) and repopulates the marker set each HUD tick from live entities — no custom canvas painter. `MinimapPanel` (same module) composes zone-label + clock header slots and a `Compass` strip around a `Minimap` — pass `zoneLabel`/`clock` nodes (e.g. from `useGameClock().calendar`) and `showCompass`/`compassProps`; omit either header slot and it collapses, `showCompass={false}` drops the strip. **Baked terrain background**: to draw the authored terrain under the markers, pass `background={document.minimap?.background}` and `mapBounds={document.minimap?.bounds}` to `Minimap`/`WorldMap` (or `MinimapPanel`, which forwards both through) — the editor's `bake_minimap` action stores a top-down terrain PNG + world bounds on `EditorDocument.minimap` (see `jgengine-editor`), and runtime reads that stored PNG with no re-rasterization. Both are undefined until baked, so an unbaked game renders the plain map unchanged. Rolling a **custom** minimap around your own course/route content (not the full `Minimap` panel)? `MinimapChrome` is the headless chrome layer other games were re-drawing by hand: nest it inside your own `<svg>` for the ring `frame`, and pass `markers: MinimapChromeMarker[]` (`{ id, position, heading?, color?, radius?, glyph? }`) for edge-clamped dots that draw as a rotated arrow whenever a marker carries a `heading` (the compass-arrow / player-blip layer) — never hand-roll `clampToMinimapEdge` + a `<polygon>` again. **Nameplates**: `WorldNameplates` (`@jgengine/shell/world/WorldHud`) billboards a name + optional 78×6px HP bar over every nearby non-local entity — turn it on declaratively via `defineGame({ nameplates: { roles, maxDistance } })` (mirrors `worldHealthBars`) rather than mounting it by hand; skin every part via `className`/`data-*` slots (`nameplateClassName`, `nameClassName`, `barClassName`, `fillClassName`) or swap the whole markup with `renderNameplate`. When the game does **not** run the JGengine entity store — it owns enemies in its own array/ECS/store — reach for the data-first **entity-frame** seam instead of `WorldNameplates`: `EntityFrames` (`@jgengine/react/entityFrames`, `entity-frames` capability) takes a caller-owned `{ id, worldPosition, ... }[]` plus a `project` (world→screen) and a `renderFrame` that composes the shipped `HealthBar` + a name (reskinned via `barTokens`/`HudTheme`); it owns only the reusable behavior — projection offset, off-screen/behind-camera culling, and nearest-on-top stacking (`layoutEntityFrames` is the pure, unit-tested core). An R3F game gets the projector for free from `WorldEntityFrames` / `useWorldProjection` (`@jgengine/shell/world/WorldEntityFrames`), which samples the live camera and composes the react primitive — overhead nameplates/health bars from your own array in a few lines, no store mirroring. Follow [the overhead entity-frames recipe](recipes/overhead-entity-frames.md). **Selection rings**: `WorldObjectHighlights` (`@jgengine/shell/world/WorldHud`) draws a ground ring over every `ctx.scene.object.selection`-ed placed object — the object-layer counterpart to `WorldEntityBars`/`WorldNameplates`; mount it once in the game's scene (`color`/`radius`/`y` props, `radius` otherwise derived from the object's catalog `halfExtents`) instead of hand-rolling a selection highlight through `WorldOverlay` against external state. **Playing cards** are already shipped, not hand-rolled: `CardFace` / `StackedPile` / `CardStack` (with `Suit`, `Rank`, `PlayingCard`) from `@jgengine/react/cards` render a French-deck face (rank + suit pips, patterned back when `faceDown`) and an overlapping fan. `StackedPile` composes the existing drag system (`useDragLayer`/`DraggableCard`/`DropZone`) — pass a `layer` for drag and `dropId` for a drop target; `CardStack` binds a fan to a headless `CardPileState` zone (`@jgengine/core/cards/cardPile`). Never draw cards on a canvas or reinvent pointer handling. **Swing timer**: `swingTimerState(player, target, prevPeriod, prevTimer)` (`@jgengine/core/ui/swingTimer`) is the pure, parameter-in/next-state-out core for a melee swing bar (recovers period on the reset edge; hidden unless auto-attacking a live non-object target) — thread the returned `nextPeriod`/`nextTimer` back via refs, render a thin `Meter`.
238
238
 
239
- **HUD widgets split into three reusable layers** — data, renderer, chrome — so a second presentation drops in without a forked widget. **Chrome**: `HudFrame` (`@jgengine/react/hudFrame`, `hud-frame` capability) is the single framed-panel primitive — `<HudFrame variation="glass"|"plate"|"retro" shape="rounded"|"circle"|"square" title aside padding width style>` renders a `data-hud-frame` div with an optional small-caps `title`/`aside` header; `glass` is byte-identical to the built-in widgets' dark-glass panel, `plate` a heavier opaque plate, `retro` a hard black-outlined frame. Need only the CSS on your own element? spread `hudFrameStyle(variation, shape)`. Reach for it instead of hand-rolling a bordered panel `<div>` per widget. **Renderer**: `MinimapTrack` (`@jgengine/react/map`, `minimap-track-hud` capability) is a structurally-different second minimap — a horizontal linear progress rail (vs the radial `Minimap`) with colored zone `spans` and gate/exit/player `pips` placed by 0..1 fraction, driven by core `trackFraction([x,z], { from, to })` (`@jgengine/core/world/minimapTrack`, `minimap-track` capability — the 1D analogue of `projectToMinimap`). It reads no store; the caller supplies fractions. Use it for corridor/route progress; use the radial `Minimap` for open worlds — both share `HudFrame` chrome. **Data**: `useLiveMarkers(rebuild, { intervalMs?, deps? })` (`@jgengine/react/hooks`, `live-markers` capability) returns a self-ticking `MarkerSet` that each HUD tick clears and re-runs your `rebuild(markers, ctx)` against the live scene — replaces the hand-rolled `useState` ticker + `useEffect` + `createMarkerSet` + `markers.clear()` + rescan boilerplate; feed the returned set straight into `Minimap`/`MinimapPanel`.
239
+ **HUD widgets split into three reusable layers** — data, renderer, chrome — so a second presentation drops in without a forked widget. **Chrome**: `HudFrame` (`@jgengine/react/hudFrame`, `hud-frame` capability) is the single framed-panel primitive — `<HudFrame variation="glass"|"plate"|"retro"|"themed" shape="rounded"|"circle"|"square" title aside padding width style>` renders a `data-hud-frame` div with an optional small-caps `title`/`aside` header; `glass` is byte-identical to the built-in widgets' dark-glass panel, `plate` a heavier opaque plate, `retro` a hard black-outlined frame, `themed` driven by the `--jg-frame-*` `HudTheme` tokens (the token-skinning path SKILL.md promotes). Need only the CSS on your own element? spread `hudFrameStyle(variation, shape)`. Reach for it instead of hand-rolling a bordered panel `<div>` per widget. **Renderer**: `MinimapTrack` (`@jgengine/react/map`, `minimap-track-hud` capability) is a structurally-different second minimap — a horizontal linear progress rail (vs the radial `Minimap`) with colored zone `spans` and gate/exit/player `pips` placed by 0..1 fraction, driven by core `trackFraction([x,z], { from, to })` (`@jgengine/core/world/minimapTrack`, `minimap-track` capability — the 1D analogue of `projectToMinimap`). It reads no store; the caller supplies fractions. Use it for corridor/route progress; use the radial `Minimap` for open worlds — both share `HudFrame` chrome. **Data**: `useLiveMarkers(rebuild, { intervalMs?, deps? })` (`@jgengine/react/hooks`, `live-markers` capability) returns a self-ticking `MarkerSet` that each HUD tick clears and re-runs your `rebuild(markers, ctx)` against the live scene — replaces the hand-rolled `useState` ticker + `useEffect` + `createMarkerSet` + `markers.clear()` + rescan boilerplate; feed the returned set straight into `Minimap`/`MinimapPanel`.
240
240
 
241
241
  **Heat/streak gauges** are already shipped, not hand-rolled `clamp(value/max)`: build the gauge with `createEventMeter` (`@jgengine/core/stats/eventMeter`, `event-meter` capability — ult/adrenaline `"hold"` mode or streak/combo `"reset"` mode over tagged event gains) and bind it to a component with `useEventMeter(meter)` (`@jgengine/react`, `event-meter-hud` capability) — it self-ticks a heartbeat and returns `{ value, fraction, tier, ready }` for a `Meter`/`BossBar`-style fill, no manual `setInterval` polling of `meter.value()`.
242
242
 
@@ -309,7 +309,7 @@ Three composable-chrome seams for FPS/TPS polish — a placement hook plus a goo
309
309
 
310
310
  **The main menu is the game's front-end, not a marketing splash.** The hosting website/runner loads straight into the game's own menu (see rejection criteria) — there is no engine-supplied main menu, and the page must not render a title card, tagline, or "enter game" button of its own. What the player first sees is a screen the game designs and skins in its own art direction: the same shape/material/type language as its HUD, not a landing page, hero banner, feature list, or store page. Homage/credit lines are fine as small print; the screen's job is to start play, not to sell it.
311
311
 
312
- Compose it from the game's own markup over the headless `StartScreen` (`@jgengine/react`, `start-screen` capability) — a full-bleed `data-jg-menu` overlay that centers your content and offers an opt-in settings corner; it imposes no look. Menu rows are the copy-in `MenuList` / `MenuButton` / `TitleScreen` registry scaffolds (keyboard/controller focus + `KeybindBadge` that self-hides on touch already wired) restyled into the game's chrome — never shipped as raw scaffold.
312
+ Compose it from the game's own markup over the headless `StartScreen` (`@jgengine/react`, `start-screen` capability) — a full-bleed `data-jg-menu` overlay that centers your content and offers an opt-in settings corner; it imposes no look. Menu rows are the game's own buttons and focus handling restyled into its chrome; the shipped pieces to compose in are `ControlsList` (`@jgengine/react`) for the control legend and `KeyHint`/`Keycap` for key badges (`KeyHint` already renders nothing on a coarse pointer) — never shipped as raw scaffold.
313
313
 
314
314
  **The front-end entries, when the game has them.** A menu is more than one Start button. Offer the real front-end verbs a player expects, gated to what the game actually supports:
315
315
 
@@ -400,6 +400,9 @@ Do not use one generic translucent controller across all games.
400
400
  - **`movement: { axis: "horizontal" }`** restricts the joystick to a steering-only zone, freeing throttle/brake to render as pedal buttons — the standard driving layout.
401
401
  - **`style`** suggests a skin: `glass` (default translucent) · `arcade` (neon bezel) · `mechanical` (metal plate) · `minimal` (thin outline). A skin changes material and geometry, not only colour. The **player overrides it in Settings → Controls** ("Touch controls", persisted); leave it on `Auto` to honour the game's `style`. This is the four-variant chooser — never hand-roll a control-skin toggle.
402
402
  - **Lifecycle actions never auto-dock.** Derivation skips `start`/`restart`/`reset`/`retry`/`pause`/`resume`/`menu`/`quit`/`exit`/`settings`/`options` — binding a `restart` key does *not* put a RESTART chip over live play (the same anti-pattern §274–283 forbids for HUD buttons, on the touch layer). These belong in `settings.actions` + a `<SettingsTrigger>` / pause menu. To dock one anyway, list it explicitly in `touch.buttons`.
403
+ - **`modes`** (`touch.modes`): per-context control sets for games whose verbs change at runtime — on foot vs driving vs flying. Each named mode's fields *replace* the matching base fields; gameplay switches with `setTouchControlsMode(ctx, "car")` (`@jgengine/core/input/touchControlsMode`, re-exported from `gameplay`) and back with `null`. A player on foot must never see flight or vehicle chrome — deriving one static dock from a multi-context input map is the button-soup anti-pattern (a blind derivation past 8 buttons warns in the console). Vice Isle is the worked example: base = five on-foot verbs, `car` = handbrake + exit, `aircraft` = throttle/yaw/airbrake cluster, switched in its driving handroll.
404
+ - **The joystick is analog.** The stick publishes per-action magnitudes (`InputFrame.analog`, radial deadzone, travel rescaled 0..1) alongside the digital `touch:` codes, the walk controller consumes them (`resolveMovementIntent` analog path — slight tilt walks slowly in that direction, never a full-speed strafe), and `ctx.input.axis(...)` samples them via `ctx.input.value(action)`, so vehicle steer/pitch/roll bound to action names get proportional control for free.
405
+ - **Two joystick variants ship in Settings → Controls** ("Joystick", persisted): `floating` (default — the dock corner is a capture zone with a faint resting ring; the stick spawns under the thumb) and `fixed` (classic always-visible stick). Never hand-roll a joystick variant toggle.
403
406
 
404
407
  Canyon Chase is the worked example: `movement: { axis: "horizontal" }` steering, `throttle`/`brake` pedals on the `right` rail, `handbrake` lever bottom-right, `style: "mechanical"`.
405
408
 
@@ -424,7 +427,7 @@ Prefer:
424
427
 
425
428
  Desktop keyboard legends must not appear on touch devices. Prompts should appear near the relevant action, object, or HUD region and clear when no longer useful.
426
429
 
427
- The shared `KeybindBadge` (and everything built on it — `MenuButton`, `MenuList`, `TitleScreen`, `ResultsScreen`) already renders nothing on a coarse pointer, so route key hints through it instead of hand-rolling `<kbd>`. When a screen hand-rolls its own control grid or a `START — {key}` / `Press Enter` call-to-action, gate that text on `const { coarsePointer } = useDisplayProfile()` (`@jgengine/react`) — hide the keyboard legend and let the tappable button carry a plain label (`Start`, `Restart`). Never surface a bare "press E / press enter" line as the only way to advance; the button itself must be tappable.
430
+ The shared `KeyHint` (`@jgengine/react`, wrapping `Keycap`) already renders nothing on a coarse pointer, so route key hints through it instead of hand-rolling `<kbd>`. When a screen hand-rolls its own control grid or a `START — {key}` / `Press Enter` call-to-action, gate that text on `const { coarsePointer } = useDisplayProfile()` (`@jgengine/react`) — hide the keyboard legend and let the tappable button carry a plain label (`Start`, `Restart`). Never surface a bare "press E / press enter" line as the only way to advance; the button itself must be tappable.
428
431
 
429
432
  The dev runner locks its own document (`overscroll-behavior: none`, `touch-action: manipulation`, no page zoom or rubber-band, no long-press text selection). Games never add scroll/pinch guards or `position: fixed` body hacks of their own; active play owns the viewport by default.
430
433