diff --git a/docs/PRDs/BLOCKED/README.md b/docs/PRDs/BLOCKED/README.md
index ab4e0dc5a7..ef2854e951 100644
--- a/docs/PRDs/BLOCKED/README.md
+++ b/docs/PRDs/BLOCKED/README.md
@@ -11,7 +11,7 @@ dependency is not ready.
| [`requires-runnable-many-soldier-consumer/`](requires-runnable-many-soldier-consumer/) | [PRD-258](requires-runnable-many-soldier-consumer/PRD-258-many-actors-share-one-animation-texture.md) | A committed Bayview consumer whose configured asset manifest exists, boots from exact current-engine tarballs, installs the playtest bridge, and completes the five pre-registered Phase 0 arms |
| [`requires-parity-rerun/`](requires-parity-rerun/) | [PRD-054](requires-parity-rerun/PRD-054-write-once-run-anywhere.md) | A clean, non-blocked cross-platform parity run |
| [`requires-touch-evidence/`](requires-touch-evidence/) | [PRD-055](requires-touch-evidence/PRD-055-native-hud-reopened.md) | Android touch-playability evidence |
-| [`requires-physical-device/`](requires-physical-device/) | [PRD-056](requires-physical-device/PRD-056-physical-mobile-qualification.md), [PRD-366](requires-physical-device/PRD-366-one-consumer-game-proves-supported-platforms.md) | Named physical Android/iOS devices, signed artifacts, and Apple credentials. PRD-360 was filed here on 2026-09-07 and returned to its batch the same day once a Pixel 8 was attached and the measurement ran. PRD-366 was filed here on 2026-09-28 under R6 (all 19 phase and 3 acceptance boxes ticked): it also wants a Windows and a macOS host with registry access and a republished `@threenative/*` cohort, so a phone is necessary but not sufficient. |
+| [`requires-physical-device/`](requires-physical-device/) | [PRD-056](requires-physical-device/PRD-056-physical-mobile-qualification.md), [PRD-366](requires-physical-device/PRD-366-one-consumer-game-proves-supported-platforms.md) | Named physical Android/iOS devices, signed artifacts, and Apple credentials. PRD-360 was filed here on 2026-09-07 and returned to its batch the same day once a Pixel 8 was attached and the measurement ran. PRD-366 was filed here on 2026-09-28 under R6 (all 19 phase and 3 acceptance boxes ticked): it also wants a Windows and a macOS host with registry access and a republished `@threenative/*` cohort, so a phone is necessary but not sufficient. PRD-880 was filed here on 2026-11-11 under R6 (all 6 phase boxes and AC-1 ticked): the owner's 8BitDo Ultimate C 2.4G read is done (`2dc8:3016`, Chromium unmapped it, a measured row now ships), leaving one official Xbox pad and a Steam Deck to read in Chromium, Firefox and the native desktop player. |
| [`review-cap/`](review-cap/) | [PRD-057](review-cap/PRD-057-native-audio-parity.md), [PRD-160](../done/PRD-160-android-emulator-lane-repair-and-parity-adjudication.md) | Specification reopen after the review cap; for PRD-160, an owner-approved parity run that reaches pixel comparison |
| [`requires-physical-proof/`](requires-physical-proof/) | [PRD-058](requires-physical-proof/PRD-058-performance-reliability-observability.md) | Current-candidate physical evidence and marker-control repair |
| [`requires-ray-measurement/`](requires-ray-measurement/) | [PRD-088](requires-ray-measurement/PRD-088-physics-spatial-queries.md) | Authoritative pre-implementation ray measurement |
diff --git a/docs/PRDs/BLOCKED/requires-physical-device/PRD-880-every-controller-reads-as-the-standard-gamepad.md b/docs/PRDs/BLOCKED/requires-physical-device/PRD-880-every-controller-reads-as-the-standard-gamepad.md
new file mode 100644
index 0000000000..f46bcc4a2f
--- /dev/null
+++ b/docs/PRDs/BLOCKED/requires-physical-device/PRD-880-every-controller-reads-as-the-standard-gamepad.md
@@ -0,0 +1,142 @@
+# PRD-880 — Every controller reads as the standard gamepad: Xbox, 8BitDo, Steam Deck
+
+**Status:** BLOCKED ON PHYSICAL DEVICE — every phase box and AC-1 are ticked and proven; what remains is the owner's read of one official Xbox pad, the 8BitDo Ultimate C 2.4G and a Steam Deck, which confirms the web table rows and the native reads.
+**Priority:** P2 — no box is done: an idle stick still drifts a character, a phantom pad still steals input, and a non-standard browser pad still fires the wrong action (AC-1 to AC-8).
+**Complexity:** 4 (MEDIUM)
+**Owner:** João (owner); an agent executes
+**Depends on:** None
+
+Complexity: 2 (about 7 implementation files) + 2 (crosses the web and native build boundaries) = 4 → MEDIUM; risk override: none.
+
+## Context
+
+The owner asked (2026-11-11) for abstractions so ThreeNative works with Xbox controllers (official and generic XInput clones), the 8BitDo Ultimate C 2.4G wireless pad, the Steam Deck and similar pads, DRY, "maybe adapter pattern".
+
+The contract already exists: a game reads every pad through `navigator.getGamepads()` in the W3C "standard" mapping, and `InputMap` binds standard indices (`buttons: [0]` is the south face button on every pad). Kits rely on it: `templates/kart/src/game.ts:13`, `platformer/src/game.ts:19`, `collectathon/src/game.ts:18`. The gaps are where a pad does not arrive in that mapping, or arrives with defects the engine leaves to the game.
+
+Current behaviour, inspected 2026-11-11:
+
+- **No dead zone anywhere.** `packages/runtime-native/src/engine/player/gamepads.h:10` says "No dead zone: core owns that", but `packages/core/src/input.ts` applies none. `vector("move")` adds raw axes 0 and 1 (`input.ts:314`), so a worn or cheap stick resting at 0.08 walks the character.
+- **First pad wins.** `InputMap.tick` (`input.ts:430`) reads the first non-null slot. A Steam Deck under Steam can expose its built-in pad and a Steam Input virtual pad. A 2.4G dongle with no pad paired, or a wheel at slot 0, takes the slot from the pad the player holds.
+- **`mapping` is ignored.** `IInputGamepad` (`input.ts:81`) has no `mapping` or `id`. A browser returns `mapping: ""` for a pad its own table does not know, for example some pads in Firefox on Linux. Raw button 0 then fires `jump` on whatever button the device numbered 0, with no warning.
+- **Two native copies of one table.** The SDL3 → standard table exists twice: `packages/runtime-native/src/engine/player/gamepads.cpp:140` (owned V8 player, 4 axes, 16 buttons) and `packages/runtime-native/src/platform/input.cpp:786` (legacy mystral host, which Android still runs: 6 axes with the triggers at 4 and 5, 17 buttons with guide at 16). A binding such as `gamepadAxes: [4]` reads a trigger on Android and nothing on desktop or web.
+- **Native already has the device database.** Both native paths open pads through SDL3 `SDL_OpenGamepad`, which applies SDL's controller database (Xbox, XInput clones, 8BitDo, Steam Deck, DualShock, DualSense). The native gap is the duplicate table, not missing devices.
+- **Search does not find it.** The `InputMap` row in `packages/core/capabilities.json` has no gamepad, controller, Xbox or Steam Deck words. An agent asked to "add Xbox controller support" finds nothing and writes its own `getGamepads` loop.
+- The playtest runner has no way to inject a pad (`packages/playtest/src/runner/steps.ts` drives keys and pointers only), so no scenario proves a controller moves a kit.
+
+## Solution
+
+**Decision: no class-per-device adapter hierarchy.** The W3C standard mapping is the one interface. Each backend has one normalizer that turns its raw pad into that mapping, and each normalizer is data, not a class:
+
+| Backend | Normalizer | Covers |
+| --- | --- | --- |
+| Native (desktop, Android, iOS) | SDL3's controller database, then one shared `fillStandardPad` table | Every pad SDL knows: Xbox, clones, 8BitDo, Steam Deck |
+| Web, `mapping === "standard"` | Pass through | Xbox and most pads in Chromium |
+| Web, `mapping === ""` | A small vendor:product → standard-index table in core, one row per measured device | 8BitDo or Steam Deck where the browser has no mapping |
+| PS4 (OpenOrbis) | The existing bit table, unchanged | DualShock 4 |
+
+Above the normalizers, `InputMap` owns the two pad conventions every game repeats: a radial dead zone and choosing the active pad. A game keeps binding standard indices and changes nothing.
+
+```mermaid
+flowchart LR
+ subgraph Native
+ SDL[SDL3 controller DB] --> FSP[fillStandardPad
one C++ table]
+ PS4[OpenOrbis bits] --> STD
+ FSP --> STD
+ end
+ subgraph Web
+ BR[navigator.getGamepads] --> M{mapping}
+ M -- standard --> STD
+ M -- empty, known id --> TBL[vendor:product table] --> STD
+ M -- empty, unknown --> WARN[TN_GAMEPAD unmapped
pad ignored]
+ end
+ STD[W3C standard pad] --> DZ[radial dead zone
gamepadDeadZone]
+ DZ --> ACT[active pad: last claimed
button or half tilt]
+ ACT --> IM[InputMap: pressed / vector / axis]
+```
+
+Conventions (on by default, each with a named override on the same object, reported):
+
+- **Dead zone:** radial, rescaled so output still reaches 1. Default 0.2, the stock Enhanced Input dead zone: `UInputModifierDeadZone` defaults to `LowerThreshold = 0.2`, `UpperThreshold = 1` and `EDeadZoneType::Radial` (`UE release: Engine/Plugins/EnhancedInput/Source/EnhancedInput/Public/InputModifiers.h:182,190,193`, read 2026-11-11 through `gh api`). Override: `defineGame({ gamepadDeadZone: 0 })`, or `input.gamepadDeadZone = 0` on a running map, for raw axes.
+- **Active pad:** a pad claims the input when a button goes down or a stick travels past half tilt, and keeps it while idle so its releases read. A stick resting past the dead zone, or one jittering across it, claims nothing — that is the difference between "the player moved" and "the hardware is old" — so no pad can steal the input at boot or in the middle of a move. At `gamepadDeadZone: 0` a noisy stick does claim, which is what raw axes mean.
+- **Report:** one `TN_GAMEPAD` line per connect, naming `id`, normalizer (`standard`, `table`, `unmapped`) and dead zone. An unmapped pad is ignored, never read with raw indices.
+
+Out of scope, each a later PRD when a kit asks: back paddles (Steam Deck L4/R4/L5/R5, 8BitDo back buttons), gyro, touchpads, rumble, analog triggers through `axis()`, and named button constants.
+
+Risks:
+
+- The web table rows for the 8BitDo and the Steam Deck come from SDL_GameControllerDB Linux entries (evdev order, which Firefox on Linux also uses). That is an assumption until a physical device confirms it (see Blocked on).
+- Unifying the native table changes the legacy host from 6 axes and 17 buttons to 4 and 16. Grep every kit and example for `gamepadAxes: [4` or `[5` and `buttons: [16` before the change lands.
+
+## Acceptance criteria
+
+The phase boxes below carry the unit and native proofs. This box is the consumer proof: a real kit, driven through `navigator.getGamepads`, using the Phase 3 `gamepad` playtest step.
+
+- [x] AC-1 [local]: In the platformer kit, a scripted standard pad with the left stick at 1 moves the player right, button 0 jumps it, and a stick at 0.1 leaves it still. proof: `node packages/playtest/dist/runner/cli.js --browser-recipe webgpu` — Evidence: the kit scaffolded from local tarballs (`createProject` + `packageLocalFramework`), built with `pnpm build:web`, then both scenarios through the repository runner against `vite preview`, headed, `--browser-recipe webgpu`. `playtests/gamepad.playtest.json` → `pass: true`: `movementDelta` x = 28.87 against `minAxisDelta: 3`, and `state.jumps` 0 → 1. `playtests/gamepad-idle.playtest.json` → `pass: true`: `movement.maxDistance` observed 0 against `maximum: 0.5`, so a stick resting at 0.1 moves nothing. Each run prints `TN_GAMEPAD {"deadZone":0.2,"id":"TN Playtest Pad (STANDARD GAMEPAD Vendor: 045e Product: 028e)","mapping":"standard"}`.
+
+## Blocked on
+
+- ~~8BitDo Ultimate C 2.4G: measured.~~ **Done 2026-11-11 on the owner's device.** The kernel reports `2dc8:3016`, which SDL 3.2.30 does not carry under any name, and Chromium reports `mapping: ""` and 15 buttons / 8 axes for it — so the shipped `301b`/`3013` rows did not cover it and the pad was ignored on the web. A measured row was added and the owner's browser now logs `TN_GAMEPAD {"deadZone":0.2,"id":"8BitDo Ultimate C 2.4G Wireless Controller (Vendor: 2dc8 Product: 3016)","mapping":"table"}`. Measured in Chromium by press: A `b0`, B `b1`, X `b3`, Y `b4`, LB `b6`, RB `b7`, Select `b10`, Start `b11`, left stick `a0`/`a1`, right stick `a2`/`a3`. The trigger sides are derived from the kernel rather than read per-frame in the page: pressing LT swept `ABS_BRAKE` (10) 0..255 and RT swept `ABS_GAS` (9) 0..255, and axes arrive in ascending ABS order — the rule the two sticks confirm — so left is `a5` and right `a4`, which also matches SDL's 8BitDo rows. The stick clicks reach raw buttons `b13`/`b14` (standard 10 and 11), measured together in the same Chromium session; which side is which follows the kernel's `BTN_THUMBL`/`BTN_THUMBR` order, since a two-stick click cannot separate them. One earlier record here was wrong and is corrected: two `KEY 312` presses seen in the kernel were LT presses, not the stick clicks, and the clicks are delivered. The only control that reaches nothing is the extra C/Z pair at `b2`/`b5`, which has no standard slot. `input.spec.ts` "maps every control the 8BitDo Ultimate C 2.4G actually sends" pins every other slot, including the right stick as `a2`/`a3` in both directions.
+- Owner read still open: an official Xbox pad and a Steam Deck, in Chromium, Firefox and the native desktop player — confirm the left stick moves and the south button jumps, and record each `TN_GAMEPAD` line. Any pad the browser reports unmapped and no row knows is named in `TN_GAMEPAD … unmapped` rather than guessed at, which is how the 8BitDo above was found.
+
+## Known follow-ups, not this PRD
+
+- The default web engine runs the game in a worker (`templates/*/src/worker.ts`), and that worker starts forwarding pads only on a `gamepadconnected` event. The browser fires one for a real pad, so a game is unaffected; the playtest `gamepad` step now announces its scripted pad the same way, which is why AC-1 passes. A game that polls `navigator.getGamepads` from the page and never listens for the event sees its pad from the first frame.
+
+## Integration Ledger
+
+| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence |
+| --- | --- | --- | --- |
+| Web pad normalization, dead zone, active pad | Kit `game.ts` → `InputMap.tick` (`packages/core/src/input.ts:430`) → `navigator.getGamepads` | First-non-null pick and raw axes are deleted | Phase 1 boxes, Phase 3 playtest |
+| Native standard pad | Game JS → `navigator.getGamepads` (`v8_main.cpp:177`; `runtime.cpp:3868`) → `fillStandardPad` | Both duplicate tables delegate to one function | Phase 2 box |
+| Capability search | Agent → `engine_search_capabilities` → `InputMap` row | New `@situation`, `@alias`, `@constraint` and `@override` tags on the existing row | Phase 3 box |
+
+## Review
+
+- 2026-11-11 (device row): the physical read landed as product code and was gated over four more rounds — the measured row for the owner's `2dc8:3016` (rounds 7 to 10). Each block was about evidence, not the mapping string: first the trigger sides presented as measured when they were derived, then the right stick unpinned in the spec, then the stick-click sides claimed as measured from a two-stick click. Final verdict on `cf363934c`: `VERDICT: AUTHORIZE`, with the one residual derivation stated: if the kernel's `BTN_THUMBL` < `BTN_THUMBR` order were wrong, a left-stick click would land on standard 11 instead of 10 — settling it needs one single-stick click in Chromium.
+- 2026-11-11: an independent reviewer subagent (fresh context, read-only, **Opus 5.5 at high thinking**) gated PR #664 over six rounds. The `opencode/claude-opus-5-5` route is disabled on this key (`permission_error: Model access is disabled`), so the review ran through the Claude CLI at `claude-cli/opus`, whose alias resolves to `claude-opus-5-5` — checked on this machine, not assumed: `claude -p … --model opus --output-format json` reports `modelUsage: {"claude-opus-5-5": …}`. Rounds 1 to 5 each found a real defect and blocked; every one is fixed and locked by a spec: the highest-slot-with-input steal, a per-tick `RegExp` allocation, a level-based hand-over that kept a stolen slot, a pad-wide edge that made jitter steal and blocked a button reclaim, and two unproven fixes (the `config.ts` temp-name collision, and the shrinking-source claim). Round 6 returned `VERDICT: AUTHORIZE`, unverified items named: CI's board, the full `pnpm native:build`, the Android/iOS/PS4 compiles, and the owner's device read below. `pnpm native:build` was attempted and cannot run on this machine at all: the dependency downloader fetches `stb` from `master` and it no longer matches its pinned hash (`TN_NATIVE_DEP_CHECKSUM_MISMATCH: payload 'stb-stb_image.h' … expected 594c2fe3…, got 0e266e9f…`), before any compilation. The Android configure in `build/tn-android` points at NDK `27.1.12297006`, which is not installed (28.2 is), so that lane cannot configure either. What did compile here: `tn_engine_only` (`platform/input.cpp`, `gamepads.cpp`, the new test) and the legacy host's `mystral-runtime` in `build/tn-linux-dual`. Its two documentation nits are folded into `2a6d52a`.
+
+## Decisions
+
+- 2026-11-11 (Claude, on the owner's "your call"): the adapter is data per backend (SDL's controller database natively, a vendor:product table on the web), not a class per device. SDL already maintains about 2 000 device mappings, and a class hierarchy would duplicate them in TypeScript. The existing `InputPlatformSource` seam stays the single injection point.
+- 2026-11-11 (Claude): an unmapped non-standard pad is ignored and reported. Reading it with raw indices fires the wrong action, which is worse than no input.
+- 2026-11-11 (agent, executing): `fillStandardPad` lives in `src/platform/input.cpp` and is declared in `mystral/platform/input.h`, not in `gamepads.cpp`. `src/platform/input.cpp` is the only file both hosts already compile (`mystral-runtime`, which the legacy `runtime.cpp` bindings poll, and `tn_engine_player`, which the V8 player links), so one definition resolves for both with no new engine source in the legacy host and no new include path. `src/engine/player/gamepads.cpp` moved from the V8 adapter executable into `tn_engine_player` for the same reason: the C++ test links both entry points into one binary.
+- 2026-11-11 (agent, executing): the dead zone applies to both stick pairs, axes 0/1 and 2/3, and never past index 3. The standard's triggers are buttons 6 and 7, so a legacy `gamepadAxes: [4]` reading no longer sees a trigger — the standard mapping replaced it (see the risk above; no kit or example binds `[4]` or `[5]`).
+- 2026-11-11 (agent, executing): a pad with no `mapping` field counts as standard. A browser always reports `"standard"` or `""`, the owned V8 player sets `mapping: "standard"`, the legacy host's `runtime.cpp` sets no `mapping` at all (so it lands here), and a fixture that omits it is standard by construction. Only the empty string — the browser's own word for "I do not know this device" — is looked up in the table.
+- 2026-11-11 (agent, executing): the `gamepad` playtest step writes the pad to `sessionStorage` as well as to a global, because a startup retry reloads the page and a held pad would otherwise vanish mid-step.
+- 2026-11-11 (agent, after four review rounds): a pad owns the input after it *claims* it — a button press, or a stick travelling past half tilt — and keeps it while idle so its releases are read; a stick resting past the dead zone or jittering across it claims nothing. Each round found a hole in the previous rule: the first picked the highest slot reporting input every tick (a steal mid-move); the second held a *level*, so a pad that claimed while the player was idle kept the slot for the session; the third switched to a pad-wide *edge*, which jitter crossing the dead zone produced every few ticks, and the player's button could not reclaim the slot because their pad already had input. The claim rule is per control, tracked as a preallocated per-slot button mask and stick travel; a slot that merely held the input without claiming is unowned, so a gentle tilt still beats an idle dongle in slot 0. `input.spec.ts` locks every round: a held pad defended against a drifter, the slot taken back after a boot-time claim, jitter never stealing while a button press reclaims, a slow walk on a later pad, and a pad that connects into a slot the browser emptied claiming on the press it already holds (empty slots forget their history).
+- 2026-11-11 (agent, measured on the owner's 8BitDo Ultimate C 2.4G): a row is only ever added from a real device, and the browser's own `mapping` decides whether one is needed at all. This device needed one — Chromium does not know `2dc8:3016` — and the row was built from the kernel's evdev order cross-checked against presses on the pad, not from a sibling's database entry. The same read twice corrected a conclusion drawn too early — two `KEY 312` presses were taken for aliased stick clicks until a retry showed the clicks arriving as raw 13 and 14 — so a device read is only finished when the browser itself has reported the control.
+- 2026-11-11 (agent, after review): the vendor:product lookup is cached per browser `id`, and the `TN_GAMEPAD` key is cached per slot:id. `RegExp.exec` and a template literal both allocate, and each ran twice a tick for the active pad, which Phase 1 promised not to do.
+- 2026-11-11 (agent, after review): `gamepadDeadZone: 0` publishes the stick untouched rather than rescaling a diagonal to the unit circle, and a dead zone outside `0..1` (or NaN) throws at construction instead of silently reading the default. `input.spec.ts` covers both.
+- 2026-11-11 (agent, after review): the d-pad stays unmapped for a `mapping: ""` pad. Every SDL row expresses it as a hat (`h0.1`), which a browser delivers as two extra axes in a device-specific order; no kit binds buttons 12 to 15, so a row that guessed would add a wrong-axis bug with no consumer.
+
+## Execution Phases
+
+#### Phase 1: Core reads any standard pad cleanly
+
+**Status:** DONE
+**Files:** `packages/core/src/input.ts` (dead zone, active pad, `mapping`/`id` on `IInputGamepad`), `packages/core/src/gamepad-mappings.ts` (new: the web vendor:product table and `toStandardPad`), `packages/core/__tests__/input.spec.ts`.
+**Implementation:** parse vendor and product from the browser `id` (Chromium `Vendor: 045e Product: 028e`, Firefox `045e-028e-…`). Normalize into the reused buffers, with no per-tick allocation (PRD-189). Apply the radial dead zone before `vector`, `axis` and `raw.gamepad.axes`.
+**Verification:** `pnpm exec vitest run packages/core/__tests__/input.spec.ts`, red first on each new test.
+
+- [x] A stick resting at 0.1 gives `vector("move")` zero, a stick at 0.6 gives 0.5 ((0.6 - 0.2) / 0.8), and `gamepadDeadZone = 0` returns the raw 0.1. proof: `input.spec.ts` dead-zone test — Evidence: `pnpm exec vitest run packages/core/__tests__/input.spec.ts` → 52 passed. "dead-zones an idle stick, rescales what is past it, and publishes raw axes at 0": 0.1 → `vector("move").length()` 0, 0.6 → `x` 0.5, `gamepadDeadZone = 0` → `x` 0.1.
+- [x] An idle pad in slot 0 does not take input from a pad in slot 1 whose button is pressed, and the slot 1 pad stays active after release. proof: `input.spec.ts` active-pad test — Evidence: same run, "keeps the pad the player last used, not the first slot the browser lists": slot 1's press reads `jump` true while slot 0 stays idle, and its release reads false. The "stays active" half is internal state only — after release both pads normalize to the same all-zero standard pad, so no public reading distinguishes them; what the rule buys is that the release is read at all, which the test asserts.
+- [x] Fixtures for an Xbox pad (`standard`), an 8BitDo Ultimate C 2.4G and a Steam Deck (both `mapping: ""`, rows from SDL_GameControllerDB) all fire `jump` on the south button, and an unknown `mapping: ""` pad fires nothing and logs `TN_GAMEPAD … unmapped` once. proof: `input.spec.ts` mapping tests — Evidence: same run, "reads a standard, an 8BitDo and a Steam Deck through one mapping" reports `["standard","table","table"]` from the `TN_GAMEPAD` lines, and the 8BitDo row also raises `crouch` from the trigger axis the row puts on raw axis 5; "ignores an unmapped pad and reports it once" holds after two ticks. The 8BitDo fixture is SDL 3.2.30's `8BitDo Ultimate 2C Wireless` (`2dc8:301b`) and `Ultimate Wireless Controller` (`2dc8:3013`) rows: the owner's Ultimate C 2.4G is not in that database under that name, so its own vendor:product is still the device read in Blocked on.
+
+#### Phase 2: One native table for every host
+
+**Status:** DONE
+**Files:** `packages/runtime-native/src/platform/input.cpp` (defines and exports `fillStandardPad`), `packages/runtime-native/include/mystral/platform/input.h` (declares it), `packages/runtime-native/src/engine/player/gamepads.cpp` (SDL branch calls it), `packages/runtime-native/tests/native-engine/player/gamepad_standard_test.cpp` (new), `cmake/NativeEngine.cmake` (the test target; `gamepads.cpp` joins `tn_engine_player`), `cmake/NativeEngineAdapters.cmake` (the adapter stops compiling its own copy). File placement differs from the plan above; see Decisions.
+**Implementation:** move the SDL branch's per-pad body into `fillStandardPad(SDL_Gamepad*, GamepadState&)`. Point the legacy host at it, so both report 4 axes and 16 buttons with the triggers as buttons 6 and 7. Leave the PS4 branch as it is.
+**Verification:** a C++ test attaches an SDL3 virtual gamepad (`SDL_AttachVirtualJoystick`), sets the stick, the trigger and the south button, and compares both entry points field by field. `pnpm native:build` compiles. The box below proves the test; the full native build was **not** run here — the engine-only target compiles `src/platform/input.cpp` and `src/engine/player/gamepads.cpp`, and the legacy host's own `src/runtime.cpp` was compiled on its own (`cmake --build build/tn-linux-dual --target mystral-runtime`), so Android, iOS and PS4 compiles stay unverified until the board runs them.
+
+- [x] `tnPollGamepads` and `platform::getGamepadState` return identical state for one virtual pad: the left stick on axes 0 and 1, the right trigger on button 7, the south button on button 0. proof: the new `tests/gamepad_standard_test.cpp` through `ctest -R gamepad_standard` — Evidence: `cmake --build build/tn-engine-only --target tn-native-engine-gamepad-standard-test` then `ctest -R gamepad_standard` → 1/1 passed (0.09 s) in `packages/runtime-native/build/tn-engine-only`. The test attaches an SDL virtual gamepad with `SDL_AttachVirtualJoystick`, adds a mapping, sets left x full, the right trigger past halfway and the south button, and requires both entry points to report 4 axes, 16 buttons and identical fields. Red check: setting `state.numAxes = 6` in the one shared table fails the same test with "the legacy host reports 4 stick axes" and "the engine player reports 4 stick axes".
+
+#### Phase 3: Agents find it, kits prove it
+
+**Status:** DONE
+**Files:** `packages/core/src/index.ts` (TSDoc tags on `InputMap`), `packages/create-threenative/conventions.json` (row `gamepad`) and `agent-docs/references/conventions.md` (its anchor), `packages/engine-mcp/__tests__/search.spec.ts`, `packages/playtest/src/runner/steps.ts` and `runner/browserSession.ts` and `runner/deviceRunner.ts` and `scenario/schema-*.ts` (the `gamepad` step), `packages/playtest/__tests__/gamepad-step.spec.ts` (new), two platformer scenarios.
+**Implementation:** add `@alias gamepad`, `controller`, `joypad`, `xbox`, `8bitdo`, `steam deck`. Add `@situation` lines for "support an Xbox or generic controller", "the character drifts while the stick is idle", "the controller does nothing in Firefox" and "a second pad steals input". Add `@constraint` lines for standard indices, the dead zone and the active pad, and `@override gamepadDeadZone`. The convention row names the default, the override and the `TN_GAMEPAD` marker, so `pnpm build` renders it into every kit `AGENTS.md`.
+
+- [x] `engine_search_capabilities` returns `InputMap` as the first row for "xbox controller", "steam deck" and "stick drift". proof: `pnpm exec vitest run packages/engine-mcp/__tests__/search.spec.ts` after `pnpm build` — Evidence: `pnpm build` regenerated `packages/core/capabilities.json` with the six aliases, four situations, three constraints and one override; the search spec → 108 passed, and the probe ranks `InputMap` first at 2.00, 2.40 and 0.61.
+- [x] The `gamepad` convention row renders into each kit `AGENTS.md`, and the convention-index check passes. proof: `pnpm build && pnpm exec vitest run packages/create-threenative/__tests__` (the conventions spec) — Evidence: the check lives in `scripts/__tests__/kit-conventions.spec.ts` (it is the spec that reads `conventions.json`, every kit's `AGENTS.md` and `CLAUDE.md`). `pnpm sync:agents` wrote 32 mirrors and the spec → 36 passed, including every kit's index carrying the row with its override.
diff --git a/packages/core/__tests__/input.spec.ts b/packages/core/__tests__/input.spec.ts
index 51b07d1f1d..21204e2c18 100644
--- a/packages/core/__tests__/input.spec.ts
+++ b/packages/core/__tests__/input.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import { InputMap, type InputPlatformSource } from "../src/input.js";
function keyEvent(type: string, code: string): Event {
@@ -84,12 +84,448 @@ describe("InputMap", () => {
it("should read a bound gamepad axis through the same scalar input", () => {
const target = new EventTarget();
const input = new InputMap({ zoom: { gamepadAxes: [3] } }, target, target, () => [
- { axes: [0, 0, 0, 0.75], buttons: [] },
+ { axes: [0, 0, 0, 1], buttons: [] },
]);
input.tick();
- expect(input.axis("zoom")).toBeCloseTo(0.75);
+ expect(input.axis("zoom")).toBe(1);
+ input.dispose();
+ });
+
+ it("dead-zones an idle stick, rescales what is past it, and publishes raw axes at 0", () => {
+ const target = new EventTarget();
+ const axes = [0.1, 0, 0, 0];
+ const input = new InputMap(undefined, target, target, () => [{ axes, buttons: [] }]);
+
+ input.tick();
+ expect(input.vector("move").length()).toBe(0);
+
+ axes[0] = 0.6;
+ input.tick();
+ expect(input.vector("move").x).toBeCloseTo(0.5);
+
+ input.gamepadDeadZone = 0;
+ axes[0] = 0.1;
+ input.tick();
+ expect(input.vector("move").x).toBeCloseTo(0.1);
+ input.dispose();
+ });
+
+ it("keeps the pad the player last used, not the first slot the browser lists", () => {
+ const target = new EventTarget();
+ const idleButton = { pressed: false };
+ const heldButton = { pressed: false };
+ const idle = { axes: [0, 0, 0, 0], buttons: [idleButton] };
+ const held = { axes: [0, 0, 0, 0], buttons: [heldButton] };
+ const input = new InputMap({ jump: { buttons: [0] } }, target, target, () => [idle, held]);
+
+ input.tick();
+ expect(input.pressed("jump")).toBe(false);
+
+ heldButton.pressed = true;
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+
+ // The release is read from the pad that pressed, not from the idle slot 0 that a first-pad
+ // rule would have sampled — and `raw.gamepad` is that pad's, normalized, not slot 0's.
+ heldButton.pressed = false;
+ input.tick();
+ expect(input.pressed("jump")).toBe(false);
+ input.dispose();
+ });
+
+ it("keeps the pad being played when another pad drifts in a higher slot", () => {
+ const target = new EventTarget();
+ // The pad in the player's hands holds the right stick; the pad in slot 1 is a worn stick resting
+ // just past the dead zone. A "last slot with input" rule hands the input to slot 1 every tick.
+ const played = { axes: [0, 0, 0, 0.9], buttons: [] };
+ const drifting = { axes: [0.3, 0, 0, 0], buttons: [] };
+ const input = new InputMap(undefined, target, target, () => [played, drifting]);
+
+ input.tick();
+ expect(input.vector("move").x).toBe(0);
+
+ // The player's pad is released. The drifter has rested past the dead zone the whole time, so it
+ // has no new edge to claim: an idle pad does not lose the input to a stick that never moved.
+ played.axes[3] = 0;
+ input.tick();
+ expect(input.vector("move").x).toBe(0);
+
+ // A gentle re-cross of the zone does not steal a slot either: the drifter re-centres and comes
+ // back to 0.3, which rescales below the claim threshold.
+ drifting.axes[0] = 0;
+ input.tick();
+ drifting.axes[0] = 0.3;
+ input.tick();
+ expect(input.vector("move").x).toBe(0);
+
+ // A deliberate move does take over: the drifter pushes the stick past half travel.
+ drifting.axes[0] = 0.9;
+ input.tick();
+ expect(input.vector("move").x).toBeCloseTo(0.875);
+ input.dispose();
+ });
+
+ it("takes the slot back when the player touches a pad the drifter took at boot", () => {
+ const target = new EventTarget();
+ // At boot the player's pad is idle and the worn stick in slot 1 already rests past the dead zone,
+ // so slot 1 owns the input. Its stick never leaves that band, so only a rising edge on the
+ // player's own pad can hand it back — a level check would leave slot 1 in charge for the session.
+ const playedButton = { pressed: false };
+ const played = { axes: [0, 0, 0, 0], buttons: [playedButton] };
+ const drifting = { axes: [0.3, 0, 0, 0], buttons: [{ pressed: false }] };
+ const input = new InputMap({ jump: { buttons: [0] } }, target, target, () => [
+ played,
+ drifting,
+ ]);
+
+ input.tick();
+ expect(input.vector("move").x).toBeCloseTo(0.125);
+
+ played.axes[0] = -1;
+ input.tick();
+ expect(input.vector("move").x).toBe(-1);
+
+ // Held down, the player's pad keeps the slot; the drifter never rises again.
+ input.tick();
+ expect(input.vector("move").x).toBe(-1);
+
+ playedButton.pressed = true;
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+ input.dispose();
+ });
+
+ it("does not lose the slot to a stick jittering across the dead zone, and a button reclaims it", () => {
+ const target = new EventTarget();
+ const playedButton = { pressed: false };
+ const played = { axes: [-1, 0, 0, 0], buttons: [playedButton] };
+ const jittering = { axes: [0.19, 0, 0, 0], buttons: [{ pressed: false }] };
+ const input = new InputMap({ jump: { buttons: [0] } }, target, target, () => [
+ played,
+ jittering,
+ ]);
+
+ input.tick();
+ expect(input.vector("move").x).toBe(-1);
+
+ // A worn stick crossing 0.2 by hundredths is an edge every few ticks, and rescales to ~0.01:
+ // it must never take the slot from a pad that is being played.
+ for (const axis of [0.21, 0.19, 0.21, 0.22]) {
+ jittering.axes[0] = axis;
+ input.tick();
+ expect(input.vector("move").x).toBe(-1);
+ }
+
+ // The player is still holding the stick, so a pad-wide edge would never fire — a button press
+ // claims the slot on its own.
+ playedButton.pressed = true;
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+ expect(input.vector("move").x).toBe(-1);
+ input.dispose();
+ });
+
+ it("lets a pad that connects into a reused slot claim on the press it already holds", () => {
+ const target = new EventTarget();
+ // Both pads press a new button, so the lowest slot claims and then releases into idle ownership;
+ // the source shrinks for a tick, and a different pad then occupies slot 1 already holding button 0.
+ // Unless the emptied slot forgets its claim history, the new pad looks like it was already down and
+ // never claims — and the owned, idle slot 0 keeps the input.
+ const ownerButton = { pressed: true };
+ const owner = { axes: [0, 0, 0, 0], buttons: [{ pressed: false }, ownerButton] };
+ const firstButton = { pressed: true };
+ const first = { axes: [0, 0, 0, 0], buttons: [firstButton] };
+ const replacementButton = { pressed: true };
+ const replacement = { axes: [0, 0, 0, 0], buttons: [replacementButton] };
+ let pads = [owner, first];
+ const input = new InputMap(
+ { fire: { buttons: [1] }, jump: { buttons: [0] } },
+ target,
+ target,
+ () => pads,
+ );
+
+ input.tick();
+ expect(input.pressed("jump")).toBe(false);
+ ownerButton.pressed = false;
+ input.tick();
+ // Slot 0 owns the input now, idle, so its releases stay readable.
+ expect(input.pressed("jump")).toBe(false);
+
+ pads = [owner];
+ input.tick();
+
+ pads = [owner, replacement];
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+ input.dispose();
+ });
+
+ it("reads a gently held stick on a later pad while the first slot has never claimed", () => {
+ const target = new EventTarget();
+ // Slot 0 is a dongle with no pad on it: it is only "first" because it is first. A player who
+ // tilts the stick to 0.4 walks without claiming, so the unowned first slot must not sit on the
+ // input — otherwise a dongle at boot would hide a slow walk until the player pressed something.
+ const empty = { axes: [0, 0, 0, 0], buttons: [] };
+ const played = { axes: [0, 0, 0, 0], buttons: [] };
+ const input = new InputMap(undefined, target, target, () => [empty, played]);
+
+ input.tick();
+ expect(input.vector("move").x).toBe(0);
+
+ played.axes[0] = 0.4;
+ input.tick();
+ expect(input.vector("move").x).toBeCloseTo(0.25);
+ input.dispose();
+ });
+
+ it("publishes raw axes at a zero dead zone, and refuses a dead zone it cannot honour", () => {
+ const target = new EventTarget();
+ // 0.6 on both axes is inside the unit circle only after clamping: a dead zone of 0 must not rescale.
+ const input = new InputMap(
+ undefined,
+ target,
+ target,
+ () => [{ axes: [0.6, 0.6, 0, 0], buttons: [] }],
+ "suppress",
+ { gamepadDeadZone: 0 },
+ );
+ input.tick();
+ expect(input.raw.gamepad.axes[0]).toBeCloseTo(0.6);
+ expect(input.raw.gamepad.axes[1]).toBeCloseTo(0.6);
+ input.dispose();
+
+ for (const gamepadDeadZone of [Number.NaN, -0.1, 1.5]) {
+ expect(
+ () => new InputMap(undefined, target, target, () => [], "suppress", { gamepadDeadZone }),
+ ).toThrow(/gamepadDeadZone must be a number from 0 through 1/);
+ }
+ });
+
+ it("reads a standard, an 8BitDo and a Steam Deck through one mapping", () => {
+ const target = new EventTarget();
+ const reports: string[] = [];
+ const info = vi.spyOn(console, "info").mockImplementation((line: string) => {
+ reports.push(line);
+ });
+ const xboxButton = { pressed: true };
+ const eightBitDoButton = { pressed: true };
+ const deckButton = { pressed: true };
+ const xbox = {
+ id: "Xbox Wireless Controller (STANDARD GAMEPAD Vendor: 045e Product: 0b13)",
+ mapping: "standard",
+ axes: [0, 0, 0, 0],
+ buttons: [xboxButton],
+ };
+ const eightBitDoAxes = [0, 0, 0, 0, 0, 1];
+ const eightBitDo = {
+ id: "8BitDo Ultimate 2C Wireless Controller (Vendor: 2dc8 Product: 301b)",
+ mapping: "",
+ // The row's own order: the left trigger is raw axis 5, the sticks are 0..3.
+ axes: eightBitDoAxes,
+ buttons: [eightBitDoButton],
+ };
+ const deck = {
+ id: "28de-1205-Steam Deck Controller",
+ mapping: "",
+ axes: [0, 0, 0, 0],
+ buttons: [deckButton],
+ };
+ let pads = [xbox];
+ const input = new InputMap(
+ { crouch: { buttons: [6, 7] }, jump: { buttons: [0] } },
+ target,
+ target,
+ () => pads,
+ );
+
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+ expect(input.pressed("crouch")).toBe(false);
+
+ xboxButton.pressed = false;
+ pads = [xbox, eightBitDo];
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+ expect(input.pressed("crouch")).toBe(true);
+
+ eightBitDoButton.pressed = false;
+ eightBitDoAxes[5] = 0;
+ pads = [xbox, eightBitDo, deck];
+ input.tick();
+ expect(input.pressed("jump")).toBe(true);
+ expect(reports.map((line) => JSON.parse(line.slice("TN_GAMEPAD ".length)).mapping)).toEqual([
+ "standard",
+ "table",
+ "table",
+ ]);
+ info.mockRestore();
+ input.dispose();
+ });
+
+ it("reads the measured 8BitDo Ultimate C 2.4G, whose browser mapping is empty", () => {
+ const target = new EventTarget();
+ const reports: string[] = [];
+ const info = vi.spyOn(console, "info").mockImplementation((line: string) => {
+ reports.push(line);
+ });
+ // The exact id Chromium printed for 2dc8:3016 on the owner's machine, including its double space.
+ // Raw order measured there: buttons 304..318 became b0..b14 (so X and Y are b3/b4) and axes
+ // ABS_X, ABS_Y, ABS_Z, ABS_RZ, ABS_GAS, ABS_BRAKE became a0..a5, so the right stick is a2/a3 and
+ // the triggers are a5 (left) and a4 (right) at -1 resting.
+ const id = "8BitDo Ultimate C 2.4G Wireless Controller (Vendor: 2dc8 Product: 3016)";
+ const pad = {
+ id,
+ mapping: "",
+ axes: [0, 0, 0, 0, -1, -1, 0, 0],
+ buttons: [
+ { pressed: true }, // 0 A
+ { pressed: false }, // 1 B
+ { pressed: false }, // 2 C
+ { pressed: true }, // 3 X
+ { pressed: false }, // 4 Y
+ { pressed: false }, // 5 Z
+ { pressed: false }, // 6 LB
+ { pressed: true }, // 7 RB
+ ],
+ };
+ const input = new InputMap(
+ { fire: { buttons: [2] }, jump: { buttons: [0] }, rightTrigger: { buttons: [7] } },
+ target,
+ target,
+ () => [pad],
+ );
+
+ input.tick();
+
+ expect(input.pressed("jump")).toBe(true); // south is raw 0 like every other pad
+ expect(input.pressed("fire")).toBe(true); // west is raw 3 on this device
+ expect(input.pressed("rightTrigger")).toBe(false); // raw 7 is the shoulder, not the trigger
+
+ // The triggers arrive as -1..1 axes; the standard reads them past halfway.
+ pad.axes[4] = 1;
+ input.tick();
+ expect(input.pressed("rightTrigger")).toBe(true);
+
+ expect(reports[0]).toContain('"mapping":"table"');
+ info.mockRestore();
+ input.dispose();
+ });
+
+ it("maps every control the 8BitDo Ultimate C 2.4G actually sends", () => {
+ const target = new EventTarget();
+ // Indices measured on the owner's device through Chromium: A, B, X, Y, LB, RB, LT, RT, Select and
+ // Start fired at 0, 1, 3, 4, 6, 7, 8, 9, 10, 11 in press order, the stick clicks at 13 and 14 (both
+ // seen in one frame; the side follows the kernel's BTN_THUMBL < BTN_THUMBR order), the
+ // left stick moved axes 0/1, the right stick axes 2/3, and the triggers sat at -1 on axes 4 and 5. One binding per standard slot
+ // the row claims, so a wrong slot in the row fails a named expectation rather than a vague one.
+ const id = "8BitDo Ultimate C 2.4G Wireless Controller (Vendor: 2dc8 Product: 3016)";
+ const pressed = Array.from({ length: 15 }, () => ({ pressed: false }));
+ const pad = { id, mapping: "", axes: [0, 0, 0, 0, -1, -1, 0, 0], buttons: pressed };
+ const bindings: Record = {
+ lookX: { gamepadAxes: [2] },
+ lookY: { gamepadAxes: [3] },
+ back: { buttons: [8] },
+ east: { buttons: [1] },
+ leftShoulder: { buttons: [4] },
+ leftStickClick: { buttons: [10] },
+ leftTrigger: { buttons: [6] },
+ north: { buttons: [3] },
+ rightShoulder: { buttons: [5] },
+ rightStickClick: { buttons: [11] },
+ rightTrigger: { buttons: [7] },
+ south: { buttons: [0] },
+ start: { buttons: [9] },
+ west: { buttons: [2] },
+ };
+ const input = new InputMap(bindings, target, target, () => [pad]);
+
+ // The pair of expectations per standard slot: which raw button lights it, and which must not.
+ const expectOnly = (name: string, raw: number): void => {
+ for (let index = 0; index < pressed.length; index += 1)
+ (pressed[index] as { pressed: boolean }).pressed = index === raw;
+ input.tick();
+ expect([name, input.pressed(name)]).toEqual([name, true]);
+ for (const other of Object.keys(bindings))
+ if (other !== name) expect([other, input.pressed(other)]).toEqual([other, false]);
+ };
+
+ expectOnly("south", 0);
+ expectOnly("east", 1);
+ expectOnly("west", 3);
+ expectOnly("north", 4);
+ expectOnly("leftShoulder", 6);
+ expectOnly("rightShoulder", 7);
+ expectOnly("back", 10);
+ expectOnly("start", 11);
+ expectOnly("leftStickClick", 13); // the clicks arrive as raw 13 and 14, not as the trigger
+ expectOnly("rightStickClick", 14);
+
+ // The triggers are `-1` at rest and `+1` pressed, read as the standard's buttons 6 and 7.
+ for (const index of [0, 1, 3, 4, 6, 7, 8, 9, 10, 11])
+ (pressed[index] as { pressed: boolean }).pressed = false;
+ pad.axes[5] = 1;
+ input.tick();
+ expect(input.pressed("leftTrigger")).toBe(true);
+ expect(input.pressed("rightTrigger")).toBe(false);
+ pad.axes[5] = -1;
+ pad.axes[4] = 1;
+ input.tick();
+ expect(input.pressed("rightTrigger")).toBe(true);
+ expect(input.pressed("leftTrigger")).toBe(false);
+
+ // Left stick on 0/1, right stick on 2/3, both rescaled past the dead zone.
+ pad.axes[0] = 1;
+ pad.axes[1] = 0;
+ input.tick();
+ expect(input.vector("move").x).toBeCloseTo(1);
+ pad.axes[0] = 0;
+ pad.axes[1] = 1;
+ input.tick();
+ expect(input.vector("move").y).toBeCloseTo(-1); // raw +y is down, so move.y is negative
+ pad.axes[1] = 0;
+ // The right stick is a2 = horizontal, a3 = vertical — not movement, and not swapped. A row with
+ // rightx:a3, or with the right stick missing, fails one of these two pairs.
+ pad.axes[2] = 1;
+ pad.axes[3] = 0;
+ input.tick();
+ expect(input.axis("lookX")).toBeCloseTo(1);
+ expect(input.axis("lookY")).toBeCloseTo(0);
+ expect(input.vector("move").x).toBe(0);
+ pad.axes[2] = 0;
+ pad.axes[3] = 1;
+ input.tick();
+ expect(input.axis("lookY")).toBeCloseTo(1);
+ expect(input.axis("lookX")).toBeCloseTo(0);
+ input.dispose();
+ });
+
+ it("ignores an unmapped pad and reports it once", () => {
+ const target = new EventTarget();
+ const reports: string[] = [];
+ const info = vi.spyOn(console, "info").mockImplementation((line: string) => {
+ reports.push(line);
+ });
+ const input = new InputMap({ jump: { buttons: [0] } }, target, target, () => [
+ {
+ id: "Generic Wheel (Vendor: 1234 Product: 5678)",
+ mapping: "",
+ axes: [0, 0, 0, 0],
+ buttons: [{ pressed: true }],
+ },
+ ]);
+
+ input.tick();
+ input.tick();
+
+ expect(input.pressed("jump")).toBe(false);
+ expect(input.raw.gamepad.buttons).toHaveLength(0);
+ expect(reports).toHaveLength(1);
+ expect(reports[0]).toContain("TN_GAMEPAD");
+ expect(reports[0]).toContain('"mapping":"unmapped"');
+ info.mockRestore();
input.dispose();
});
@@ -264,10 +700,7 @@ describe("InputMap", () => {
it("uses the first connected gamepad after empty browser slots", () => {
const target = new EventTarget();
- const gamepads = [
- null,
- { axes: [0, 0, 0, 0.75], buttons: [] },
- ] as ReturnType;
+ const gamepads = [null, { axes: [0, 0, 0, 1], buttons: [] }] as ReturnType;
Object.defineProperty(gamepads, "find", {
configurable: true,
value: () => {
@@ -278,7 +711,7 @@ describe("InputMap", () => {
input.tick();
- expect(input.axis("zoom")).toBeCloseTo(0.75);
+ expect(input.axis("zoom")).toBe(1);
input.dispose();
});
diff --git a/packages/core/capabilities.json b/packages/core/capabilities.json
index 435153b02c..db3df42439 100644
--- a/packages/core/capabilities.json
+++ b/packages/core/capabilities.json
@@ -2175,21 +2175,28 @@
"symbol": "GroundSnap"
},
{
- "aliases": [],
+ "aliases": ["gamepad", "controller", "joypad", "xbox", "8bitdo", "steam deck"],
"constraints": [
"`buttons` is the gamepad and `mouseButtons` the mouse; `up`/`down`/`left`/`right` are the directions of `vector(name)`, not the keys that press it",
- "scroll, pinch and pointer-relative sources are declared on the binding and read through `axis(name)`; a game adds no window listener of its own"
+ "scroll, pinch and pointer-relative sources are declared on the binding and read through `axis(name)`; a game adds no window listener of its own",
+ "gamepad buttons are W3C standard indices on every pad: 0 south (A/Cross), 1 east, 2 west, 3 north, 4/5 shoulders, 6/7 triggers, 8 select, 9 start, 10/11 stick clicks; axes 0..3 are the sticks",
+ "both sticks carry a radial dead zone, `0.2` by default, reported per connected pad as `TN_GAMEPAD`; a pad the browser reports unmapped and the table does not know is ignored, never read with raw indices",
+ "a pad claims the input when a button goes down or a stick travels past half tilt, and keeps it while idle so its releases read; a stick resting past the dead zone or jittering across it claims nothing, so an idle pad in the first slot cannot steal input"
],
"example": "const game = defineGame({ input: { move: { up: [\"KeyW\"], down: [\"KeyS\"], left: [\"KeyA\"], right: [\"KeyD\"] } }, scenes: { Play } });\n// inside the scene, per frame: ctx.input.axis(\"move\") is 0 at rest and 1 at full tilt",
"importPath": "@threenative/core",
"kind": "class",
- "overrides": [],
+ "overrides": ["defineGame({ gamepadDeadZone: 0 }) publishes raw axes"],
"package": "@threenative/core",
"signature": "export class InputMap { … }",
"situations": [
"map WASD keys to a movement axis instead of reading the held key set in the update loop",
"read a jump, a fire or a reload as one named action bound to key, gamepad button and mouse button together",
- "read mouse look, wheel zoom or a two-finger pinch as an axis the frame loop already ticks"
+ "read mouse look, wheel zoom or a two-finger pinch as an axis the frame loop already ticks",
+ "support an Xbox pad, a generic XInput clone, an 8BitDo or a Steam Deck controller",
+ "the character drifts while the controller stick is idle",
+ "the controller does nothing in Firefox on Linux",
+ "a second controller or a wheel steals the input from the pad the player holds"
],
"summary": "The map a game reads input through: named actions, 2D vectors and scalar axes resolved from keyboard, gamepad, mouse, wheel, pinch and touch. `defineGame({ input })` builds one and hands it to the running game as `ctx.input`, so the usual route is a binding in the config and `ctx.input.axis(\"move\")` in the update. Construct one directly to drive a menu, a replay or a test outside a running game.",
"supersedes": [],
diff --git a/packages/core/src/game-startup.ts b/packages/core/src/game-startup.ts
index 53f49297c5..bc94b74cce 100644
--- a/packages/core/src/game-startup.ts
+++ b/packages/core/src/game-startup.ts
@@ -187,6 +187,7 @@ export async function startGameWorld, TPh
canvas,
platform?.input,
game.config.contextMenu,
+ { gamepadDeadZone: game.config.gamepadDeadZone },
);
game.input = input;
game.cleanup.push(() => {
diff --git a/packages/core/src/game.ts b/packages/core/src/game.ts
index 7f1eabd9ac..b8bb837a3b 100644
--- a/packages/core/src/game.ts
+++ b/packages/core/src/game.ts
@@ -209,6 +209,12 @@ export interface IGameConfig<
* the browser menu over its canvas.
*/
readonly contextMenu?: ContextMenuPolicy;
+ /**
+ * Radial dead zone for both controller sticks, `0`..`1`. Default `0.2`, the stock Enhanced Input
+ * value, so a worn stick resting at `0.08` reads zero instead of walking the character. A stick
+ * past the zone is rescaled so full tilt is still `1`. Pass `0` to read raw axes.
+ */
+ readonly gamepadDeadZone?: number;
/**
* Per-frame cost attribution, on by default. Every `reportEvery` presented frames the game
* prints one `TN_FRAME_BUDGET` line naming where the frame went — present wait, simulation,
diff --git a/packages/core/src/gamepad-mappings.ts b/packages/core/src/gamepad-mappings.ts
new file mode 100644
index 0000000000..802bf9f46b
--- /dev/null
+++ b/packages/core/src/gamepad-mappings.ts
@@ -0,0 +1,269 @@
+import type { IInputGamepad } from "./input.js";
+
+/**
+ * The web half of the one interface a game reads a controller through: the W3C "standard"
+ * mapping. A pad the browser already maps needs nothing; a pad it reports unmapped
+ * (`mapping: ""`, which is the browser saying "I do not know this device") is turned into the
+ * standard layout by one table row per measured device, never by a class per model.
+ *
+ * The rows are SDL_GameControllerDB entries, copied verbatim and stamped with the line they came
+ * from (`third_party/sdl3/SDL3-3.2.30/src/joystick/SDL_gamepad_db.h`). The database carries several
+ * GUID forms per device — a USB hidraw entry, a Bluetooth one, and one per host driver — and which
+ * of them a browser reports is the device read still open on this PRD. What the rows do here is name
+ * the slots, and for every form of these devices the sticks and the face/shoulder/start/back/stick
+ * buttons are the same; the triggers are axes in the forms below and buttons in the
+ * `SDL_JOYSTICK_DINPUT` (Windows) form at `:72`, which no browser reports.
+ *
+ * The d-pad is deliberately not mapped: every row expresses it as a hat (`h0.1`), which arrives in a
+ * browser as two extra axes in a device-specific order. No kit here binds the d-pad, so a row ships
+ * the sticks, the triggers and the face/shoulder/start/back/stick-click buttons only.
+ */
+
+/** Which normalizer read a pad — the word a `TN_GAMEPAD` line reports. */
+export type GamepadNormalizer = "standard" | "table" | "unmapped";
+
+/** The W3C standard's 16 buttons; 6 and 7 are the analog triggers, read from axes. */
+export const STANDARD_BUTTON_COUNT = 16;
+
+/** The standard's 4 stick axes: left x/y, then right x/y. */
+export const STANDARD_AXIS_COUNT = 4;
+
+/**
+ * A mutable standard pad: the buffer a normalizer fills and `InputMap` publishes. One is allocated
+ * per pad the reader has to normalize, then mutated every tick — normalizing must not allocate in
+ * the frame loop.
+ */
+export interface IStandardPad {
+ readonly axes: number[];
+ readonly buttons: { pressed: boolean }[];
+}
+
+export function createStandardPad(): IStandardPad {
+ const buttons: { pressed: boolean }[] = [];
+ for (let index = 0; index < STANDARD_BUTTON_COUNT; index += 1) buttons.push({ pressed: false });
+ return { axes: [0, 0, 0, 0], buttons };
+}
+
+/** One device the browser cannot map, keyed by the vendor:product its `id` carries. */
+interface IPadRow {
+ /** The SDL_GameControllerDB mapping string, verbatim. */
+ readonly map: string;
+ readonly product: number;
+ readonly vendor: number;
+}
+
+const PAD_ROWS: readonly IPadRow[] = [
+ {
+ // 8BitDo Ultimate C 2.4G Wireless Controller, **measured on the owner's device** (2026-11-11):
+ // the kernel reports `2dc8:3016` and Chromium reports `mapping: ""` for it, so this row is the
+ // only thing that makes the pad work on the web. SDL 3.2.30 has no row for `3016` under any name
+ // (its 8BitDo entries are `301b`, `3013`, `3011`, `3012`), so nothing could be copied.
+ // Linux sorts an unmapped pad's buttons and axes by evdev code, and the measured device exposes
+ // KEY 304..318 and ABS 0,1,2,5,9,10,16,17 — so the raw indices are that order: b0..b14 are
+ // A, B, C, X, Y, Z, LB, RB, LT, RT, Select, Start, Home, L3, R3, and a0..a7 are ABS_X, ABS_Y,
+ // ABS_Z, ABS_RZ, ABS_GAS, ABS_BRAKE, HAT0X, HAT0Y. Hence X and Y sit on b3/b4 (not b2/b3), the
+ // right stick is a2/a3, and the triggers are a5 (left) and a4 (right), travelling -1..1.
+ // What was measured in Chromium, by pressing the pad: A, B, X, Y, LB, RB, Select and Start at raw
+ // 0, 1, 3, 4, 6, 7, 10, 11, the left stick on a0/a1 and the right stick on a2/a3. The trigger sides
+ // are derived from the kernel instead, which is the closest measurement available without a
+ // per-frame logger in the page: pressing LT swept `ABS_BRAKE` (10) 0..255 and pressing RT swept
+ // `ABS_GAS` (9) 0..255, and axes are reported in ascending ABS order — the rule the two sticks
+ // confirm, ABS_X/ABS_Y on a0/a1 and ABS_Z/ABS_RZ on a2/a3 — which puts the left trigger on a5 and
+ // the right on a4, as SDL's 8BitDo rows also have them.
+ // The stick clicks reach Chromium as raw buttons 13 and 14, which is the standard's 10 and 11 —
+ // observed when both sticks were clicked together, so the indices are measured while which side is
+ // which follows the kernel's order (`BTN_THUMBL` 317 before `BTN_THUMBR` 318), the same derivation
+ // the triggers use. One caveat: the extra C/Z buttons at b2/b5 have no standard slot, so they are
+ // left unbound.
+ product: 0x3016,
+ vendor: 0x2dc8,
+ map: "a:b0,b:b1,x:b3,y:b4,back:b10,start:b11,leftshoulder:b6,rightshoulder:b7,leftstick:b13,rightstick:b14,lefttrigger:a5,righttrigger:a4,leftx:a0,lefty:a1,rightx:a2,righty:a3,",
+ },
+ {
+ // 8BitDo Ultimate 2C Wireless, SDL_GameControllerDB:367 (`0300…1b30000001000000`), whose
+ // triggers are axes 5 and 4 — the form a browser reports, where the `SDL_JOYSTICK_DINPUT`
+ // entry (:72) has them as buttons 8 and 9 and nothing on the web would ever see those.
+ product: 0x301b,
+ vendor: 0x2dc8,
+ map: "a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,paddle1:b2,paddle2:b5,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,",
+ },
+ {
+ // 8BitDo Ultimate Wireless Controller, SDL_GameControllerDB:369 (`0300…1330000000020000`).
+ product: 0x3013,
+ vendor: 0x2dc8,
+ map: "a:b0,b:b1,back:b10,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b12,leftshoulder:b6,leftstick:b13,lefttrigger:a5,leftx:a0,lefty:a1,misc1:b26,paddle1:b23,paddle2:b19,rightshoulder:b7,rightstick:b14,righttrigger:a4,rightx:a2,righty:a3,start:b11,x:b3,y:b4,",
+ },
+ {
+ // Steam Deck Controller, SDL_GameControllerDB:756 (`0300…0512000000016800`). Steam Input's virtual
+ // pad is a different SDL entry (:757) with the face buttons on b3..b6, and its d-pad as buttons;
+ // that one is not covered here.
+ product: 0x1205,
+ vendor: 0x28de,
+ map: "a:b0,b:b1,back:b4,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,dpup:h0.1,guide:b5,leftshoulder:b9,leftstick:b7,lefttrigger:a4,leftx:a0,lefty:a1,misc1:b11,paddle1:b12,paddle2:b13,paddle3:b14,paddle4:b15,rightshoulder:b10,rightstick:b8,righttrigger:a5,rightx:a2,righty:a3,start:b6,x:b2,y:b3,",
+ },
+];
+
+/** SDL's names for the standard slots, so a copied row needs no renumbering to be read. */
+const BUTTON_NAMES: Readonly> = {
+ a: 0,
+ b: 1,
+ x: 2,
+ y: 3,
+ leftshoulder: 4,
+ rightshoulder: 5,
+ back: 8,
+ start: 9,
+ leftstick: 10,
+ rightstick: 11,
+};
+
+const AXIS_NAMES: Readonly> = {
+ leftx: 0,
+ lefty: 1,
+ rightx: 2,
+ righty: 3,
+};
+
+const TRIGGER_SLOTS: Readonly> = {
+ lefttrigger: 6,
+ righttrigger: 7,
+};
+
+/** Where one row sends a standard slot: an index into the pad's buttons or its axes. */
+type PadSlot =
+ | { readonly index: number; readonly kind: "button" }
+ | { readonly index: number; readonly invert: boolean; readonly kind: "axis" };
+
+interface IParsedRow {
+ /** Standard axis index -> where the pad keeps it. */
+ readonly axes: readonly (PadSlot | undefined)[];
+ /** Standard button index -> where the pad keeps it. */
+ readonly buttons: readonly (PadSlot | undefined)[];
+}
+
+function parseSlot(value: string): PadSlot | undefined {
+ const invert = value.startsWith("-");
+ const token = invert || value.startsWith("+") ? value.slice(1) : value;
+ const index = Number.parseInt(token.slice(1), 10);
+ if (!Number.isInteger(index) || index < 0) return undefined;
+ if (token.startsWith("b")) return { index, kind: "button" };
+ if (token.startsWith("a")) return { index, invert, kind: "axis" };
+ return undefined;
+}
+
+function parseRow(row: IPadRow): IParsedRow {
+ const axes: (PadSlot | undefined)[] = [];
+ const buttons: (PadSlot | undefined)[] = [];
+ for (const token of row.map.split(",")) {
+ const colon = token.indexOf(":");
+ if (colon < 1) continue;
+ const slot = parseSlot(token.slice(colon + 1));
+ if (slot === undefined) continue; // a hat, a paddle or the guide button: no standard slot
+ const name = token.slice(0, colon);
+ const axis = AXIS_NAMES[name];
+ if (axis !== undefined) axes[axis] = slot;
+ const button = BUTTON_NAMES[name] ?? TRIGGER_SLOTS[name];
+ if (button !== undefined) buttons[button] = slot;
+ }
+ return { axes, buttons };
+}
+
+/** Parsed once per row: parsing the same string every tick is work the frame loop must not do. */
+const PARSED_ROWS = new Map(PAD_ROWS.map((row) => [row, parseRow(row)]));
+
+/**
+ * Row per browser `id`. A pad keeps its id while it is connected, so this answer never changes for a
+ * connected device — and `RegExp.exec` allocates a match array, which the frame loop must not do.
+ */
+const ROW_BY_ID = new Map();
+
+const CHROMIUM_VENDOR_PRODUCT = /vendor:\s*([0-9a-f]{1,4})\s+product:\s*([0-9a-f]{1,4})/i;
+const FIREFOX_VENDOR_PRODUCT = /^([0-9a-f]{4})-([0-9a-f]{4})-/i;
+
+/**
+ * The vendor:product a browser `id` carries. Chromium writes `… Vendor: 045e Product: 028e` inside
+ * the parentheses, Firefox writes `045e-028e-`. Anything else answers `undefined`, which is
+ * what makes such a pad unmapped rather than guessed at.
+ */
+export function padVendorProduct(
+ id: string | undefined,
+): { readonly product: number; readonly vendor: number } | undefined {
+ if (id === undefined) return undefined;
+ const match = CHROMIUM_VENDOR_PRODUCT.exec(id) ?? FIREFOX_VENDOR_PRODUCT.exec(id);
+ if (match === null) return undefined;
+ const vendor = Number.parseInt(match[1] as string, 16);
+ const product = Number.parseInt(match[2] as string, 16);
+ if (!Number.isInteger(vendor) || !Number.isInteger(product)) return undefined;
+ return { product, vendor };
+}
+
+function readSlot(pad: IInputGamepad, slot: PadSlot): number {
+ if (slot.kind === "button") return pad.buttons[slot.index]?.pressed === true ? 1 : 0;
+ const value = pad.axes[slot.index];
+ if (value === undefined || !Number.isFinite(value)) return 0;
+ return slot.invert ? -value : value;
+}
+
+/**
+ * Fills `out` with `pad` in the W3C standard layout and says which normalizer read it.
+ *
+ * A pad that reports `mapping: "standard"` — or no `mapping` field at all, which is what the owned
+ * native host and a test fixture report — passes through. A pad reporting the empty string is
+ * looked up by vendor:product; `unmapped` means the caller must ignore it, because reading raw
+ * indices fires whatever action the device happened to number 0.
+ */
+export function toStandardPad(pad: IInputGamepad, out: IStandardPad): GamepadNormalizer {
+ const mapping = pad.mapping;
+ const parsed =
+ mapping === undefined || mapping === "standard"
+ ? undefined
+ : PARSED_ROWS.get(rowFor(pad.id) as IPadRow);
+ if (mapping === undefined || mapping === "standard") {
+ copyPad(pad, out);
+ return "standard";
+ }
+ if (parsed === undefined) {
+ clearPad(out);
+ return "unmapped";
+ }
+ for (let index = 0; index < STANDARD_AXIS_COUNT; index += 1) {
+ const slot = parsed.axes[index];
+ out.axes[index] = slot === undefined ? 0 : readSlot(pad, slot);
+ }
+ for (let index = 0; index < STANDARD_BUTTON_COUNT; index += 1) {
+ const slot = parsed.buttons[index];
+ // A trigger is an axis on these devices; the standard's button 6/7 is down past halfway.
+ const value = slot === undefined ? 0 : readSlot(pad, slot);
+ (out.buttons[index] as { pressed: boolean }).pressed = value > 0.5;
+ }
+ return "table";
+}
+
+function rowFor(id: string | undefined): IPadRow | undefined {
+ if (id === undefined) return undefined;
+ const cached = ROW_BY_ID.get(id);
+ if (cached !== undefined) return cached ?? undefined;
+ const key = padVendorProduct(id);
+ let found: IPadRow | undefined;
+ if (key !== undefined)
+ for (const row of PAD_ROWS)
+ if (row.vendor === key.vendor && row.product === key.product) found = row;
+ ROW_BY_ID.set(id, found ?? null);
+ return found;
+}
+
+function clearPad(out: IStandardPad): void {
+ for (let index = 0; index < STANDARD_AXIS_COUNT; index += 1) out.axes[index] = 0;
+ for (let index = 0; index < STANDARD_BUTTON_COUNT; index += 1)
+ (out.buttons[index] as { pressed: boolean }).pressed = false;
+}
+
+function copyPad(pad: IInputGamepad, out: IStandardPad): void {
+ const axes = pad.axes;
+ for (let index = 0; index < STANDARD_AXIS_COUNT; index += 1) {
+ const value = axes[index];
+ out.axes[index] = value !== undefined && Number.isFinite(value) ? value : 0;
+ }
+ for (let index = 0; index < STANDARD_BUTTON_COUNT; index += 1)
+ (out.buttons[index] as { pressed: boolean }).pressed = pad.buttons[index]?.pressed === true;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 46a1a38467..ac2c57f319 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1988,6 +1988,7 @@ export type {
ContextMenuPolicy,
IInputAction,
IInputGamepad,
+ IInputMapOptions,
InputBindings,
InputPlatformSource,
IRawInputPointer,
@@ -2005,8 +2006,22 @@ export type {
* @situation map WASD keys to a movement axis instead of reading the held key set in the update loop
* @situation read a jump, a fire or a reload as one named action bound to key, gamepad button and mouse button together
* @situation read mouse look, wheel zoom or a two-finger pinch as an axis the frame loop already ticks
+ * @situation support an Xbox pad, a generic XInput clone, an 8BitDo or a Steam Deck controller
+ * @situation the character drifts while the controller stick is idle
+ * @situation the controller does nothing in Firefox on Linux
+ * @situation a second controller or a wheel steals the input from the pad the player holds
+ * @alias gamepad
+ * @alias controller
+ * @alias joypad
+ * @alias xbox
+ * @alias 8bitdo
+ * @alias steam deck
* @constraint `buttons` is the gamepad and `mouseButtons` the mouse; `up`/`down`/`left`/`right` are the directions of `vector(name)`, not the keys that press it
* @constraint scroll, pinch and pointer-relative sources are declared on the binding and read through `axis(name)`; a game adds no window listener of its own
+ * @constraint gamepad buttons are W3C standard indices on every pad: 0 south (A/Cross), 1 east, 2 west, 3 north, 4/5 shoulders, 6/7 triggers, 8 select, 9 start, 10/11 stick clicks; axes 0..3 are the sticks
+ * @constraint both sticks carry a radial dead zone, `0.2` by default, reported per connected pad as `TN_GAMEPAD`; a pad the browser reports unmapped and the table does not know is ignored, never read with raw indices
+ * @constraint a pad claims the input when a button goes down or a stick travels past half tilt, and keeps it while idle so its releases read; a stick resting past the dead zone or jittering across it claims nothing, so an idle pad in the first slot cannot steal input
+ * @override defineGame({ gamepadDeadZone: 0 }) publishes raw axes
* @example const game = defineGame({ input: { move: { up: ["KeyW"], down: ["KeyS"], left: ["KeyA"], right: ["KeyD"] } }, scenes: { Play } });
* // inside the scene, per frame: ctx.input.axis("move") is 0 at rest and 1 at full tilt
*/
diff --git a/packages/core/src/input.ts b/packages/core/src/input.ts
index fc23441f46..c5dc5a15f4 100644
--- a/packages/core/src/input.ts
+++ b/packages/core/src/input.ts
@@ -1,4 +1,10 @@
import { Vector2 } from "three";
+import {
+ type GamepadNormalizer,
+ type IStandardPad,
+ createStandardPad,
+ toStandardPad,
+} from "./gamepad-mappings.js";
/**
* One action, read either as a button through `pressed`/`justPressed`, a 2D axis through
@@ -81,10 +87,27 @@ function mouseButtonMask(button: number): number {
export interface IInputGamepad {
readonly axes: ArrayLike;
readonly buttons: readonly { readonly pressed: boolean }[];
+ /** The browser's device name, which carries the vendor:product an unmapped pad is looked up by. */
+ readonly id?: string;
+ /**
+ * `"standard"` when the pad already uses the W3C standard layout, and `""` when the browser does
+ * not know the device. A source with no `mapping` at all is standard by construction: the owned
+ * native host builds standard pads, and so does a fixture.
+ */
+ readonly mapping?: string;
}
export type InputPlatformSource = () => readonly (IInputGamepad | null)[];
+export interface IInputMapOptions {
+ /**
+ * Radial dead zone for both sticks, `0`..`1`. Default `0.2`, the stock Enhanced Input dead zone
+ * (`UInputModifierDeadZone`, `LowerThreshold = 0.2`, `EDeadZoneType::Radial`). A stick inside it
+ * reads zero, one outside it is rescaled so full tilt is still `1`. `0` publishes raw axes.
+ */
+ readonly gamepadDeadZone?: number;
+}
+
export interface IRawInputPointer {
readonly id: number;
buttons: number;
@@ -158,6 +181,41 @@ function pointerLockDocument(): PointerLockDocument | undefined {
const browserGamepads: InputPlatformSource = () =>
(globalThis.navigator as Navigator | undefined)?.getGamepads?.() ?? [];
+const DEFAULT_GAMEPAD_DEAD_ZONE = 0.2;
+
+/**
+ * How far past the dead zone a stick must travel to claim the input from another pad, on the
+ * rescaled axis: half travel. A worn stick jittering around the zone rescales to about `0.01` and
+ * never claims, while a deliberate move always does. A button press claims at any deflection.
+ */
+const GAMEPAD_CLAIM_STICK = 0.5;
+
+/** Stable names for pads a fixture reports with no `id`, so reporting allocates nothing per tick. */
+const PAD_SLOT_KEYS: readonly string[] = Array.from({ length: 16 }, (_, index) => `slot-${index}`);
+
+/**
+ * The radial dead zone, in place. A stick inside `deadZone` reads zero; one outside is rescaled by
+ * `(magnitude - deadZone) / (1 - deadZone)` so the edge of the zone is still `0` and full tilt is
+ * still `1`, which is what makes a 0.6 stick read 0.5 rather than 0.4.
+ */
+function applyStickDeadZone(axes: number[], x: number, y: number, deadZone: number): void {
+ // `0` is the documented raw-axes override, so it must publish the stick untouched — the rescale
+ // below would still pull a corner in to the unit circle.
+ if (!(deadZone > 0)) return;
+ const zone = Math.min(deadZone, 1);
+ const stickX = axes[x] ?? 0;
+ const stickY = axes[y] ?? 0;
+ const magnitude = Math.sqrt(stickX * stickX + stickY * stickY);
+ if (magnitude <= zone || zone >= 1) {
+ axes[x] = 0;
+ axes[y] = 0;
+ return;
+ }
+ const scale = Math.min(1, (magnitude - zone) / (1 - zone)) / magnitude;
+ axes[x] = stickX * scale;
+ axes[y] = stickY * scale;
+}
+
/**
* Ask the browser to lock the pointer to `target`, the capture every first-person mouse look needs.
*
@@ -195,6 +253,20 @@ export class InputMap {
#pinchSample = 0;
#gamepadAxes: number[] = [];
#gamepadButtons: boolean[] = [];
+ /** The pad every read goes through: one reused buffer, filled once per read and never allocated. */
+ #padScratch: IStandardPad = createStandardPad();
+ /** Which source slot the player last used. It stays active until another pad reports input. */
+ #activePadSlot = -1;
+ #padSeen = new Set();
+ #padReports = new Map();
+ /** Each slot's pressed-button mask and stick travel last tick: a pad takes the slot on a claim. */
+ #padButtonMask: number[] = [];
+ #padStick: number[] = [];
+ /** Whether the current pad took the slot by claiming it, rather than by being the only pad there. */
+ #activePadClaimed = false;
+ /** The `TN_GAMEPAD` key per slot, and the `id` it was built from, so a tick allocates no string. */
+ #padKeys: Array = [];
+ #padKeyIds: Array = [];
#previousPressed = new Map();
#justPressed = new Set();
#justReleased = new Set();
@@ -214,13 +286,28 @@ export class InputMap {
#tracksPinch = false;
#listeners: Array<[EventTarget, string, EventListener]> = [];
+ /**
+ * Radial dead zone for both sticks; `0` publishes the raw axes. Read on every tick, so a game may
+ * change it while running, and a value that is not a finite number from 0 through 1 reads as raw
+ * axes. `defineGame({ gamepadDeadZone })` sets the starting value and refuses one it cannot honour.
+ */
+ gamepadDeadZone: number;
+
constructor(
bindings: InputBindings = DEFAULT_BINDINGS,
target: EventTarget = globalThis,
pointerTarget: EventTarget = target,
source: InputPlatformSource = browserGamepads,
contextMenu: ContextMenuPolicy = "suppress",
+ options: IInputMapOptions = {},
) {
+ const deadZone = options.gamepadDeadZone ?? DEFAULT_GAMEPAD_DEAD_ZONE;
+ if (!Number.isFinite(deadZone) || deadZone < 0 || deadZone > 1) {
+ throw new RangeError(
+ `gamepadDeadZone must be a number from 0 through 1, received ${deadZone}.`,
+ );
+ }
+ this.gamepadDeadZone = deadZone;
this.#bindings = { ...DEFAULT_BINDINGS, ...bindings };
// Bindings are fixed at construction; freezing the key list keeps tick() from calling
// Object.keys every step.
@@ -426,29 +513,7 @@ export class InputMap {
this.#scrollSample = this.#clampAxis(this.#scrollDelta);
this.#scrollDelta = 0;
this.#samplePinch();
- const sources = this.#source();
- let gamepad: IInputGamepad | undefined;
- for (let index = 0; index < sources.length; index += 1) {
- const candidate = sources[index];
- if (candidate !== null) {
- gamepad = candidate;
- break;
- }
- }
- // Reused buffers: fresh arrays here were four allocations per update step, device or not.
- this.#gamepadAxes.length = 0;
- this.#gamepadButtons.length = 0;
- if (gamepad?.axes) {
- const axes = gamepad.axes;
- for (let axis = 0; axis < axes.length; axis += 1)
- this.#gamepadAxes.push(axes[axis] as number);
- }
- if (gamepad?.buttons) {
- for (let index = 0; index < gamepad.buttons.length; index += 1)
- this.#gamepadButtons.push(gamepad.buttons[index]?.pressed ?? false);
- }
- this.raw.gamepad.axes = this.#gamepadAxes;
- this.raw.gamepad.buttons = this.#gamepadButtons;
+ this.#sampleGamepad();
this.#justPressed.clear();
this.#justReleased.clear();
for (let index = 0; index < this.#bindingNames.length; index += 1) {
@@ -486,6 +551,7 @@ export class InputMap {
this.#pinchPair = undefined;
this.#pinchDistance = 0;
this.#pinchSample = 0;
+ this.#activePadSlot = -1;
this.#previousPressed.clear();
this.#justPressed.clear();
this.#justReleased.clear();
@@ -505,6 +571,146 @@ export class InputMap {
this.clear();
}
+ /**
+ * Reads every pad once, normalizes each to the standard mapping, and publishes the one pad the
+ * player is holding. Reading the first non-null slot instead let a wheel or a paired-but-idle
+ * dongle at slot 0 swallow the pad in the player's hands.
+ */
+ #sampleGamepad(): void {
+ const sources = this.#source();
+ this.#padSeen.clear();
+ let claimSlot = -1;
+ let inputSlot = -1;
+ let firstMapped = -1;
+ let activeStillMapped = false;
+ for (let index = 0; index < sources.length; index += 1) {
+ const pad = sources[index];
+ if (pad === null || pad === undefined) {
+ this.#forgetPad(index);
+ continue;
+ }
+ if (this.#readPad(pad, index) === "unmapped") {
+ this.#forgetPad(index);
+ continue;
+ }
+ if (firstMapped < 0) firstMapped = index;
+ // A claim, not a level and not a pad-wide edge: a worn stick resting past the dead zone reports
+ // input on every tick, and one jittering across the zone produces an edge every few ticks, so
+ // either test would let a phantom pad take the slot in the middle of a real move — the bug this
+ // PRD exists to remove. A wedge either presses a button that was up, or travels past half a
+ // stick; a resting stick does neither, and a button press reclaims even while the player is
+ // already holding their stick still.
+ const mask = this.#padButtonMaskNow();
+ const stick = this.#padStickNow();
+ const pressedNewButton = (mask & ~(this.#padButtonMask[index] ?? 0)) !== 0;
+ const travelled =
+ stick >= GAMEPAD_CLAIM_STICK && (this.#padStick[index] ?? 0) < GAMEPAD_CLAIM_STICK;
+ if ((pressedNewButton || travelled) && claimSlot < 0) claimSlot = index;
+ this.#padButtonMask[index] = mask;
+ this.#padStick[index] = stick;
+ if (this.#padHasInput() && inputSlot < 0) inputSlot = index;
+ if (index === this.#activePadSlot) activeStillMapped = true;
+ }
+ // A source that shrinks never visits its old slots again, so they are forgotten here; otherwise a
+ // pad that later connects into one of them would inherit the previous pad's claim history.
+ for (let index = sources.length; index < this.#padButtonMask.length; index += 1)
+ this.#forgetPad(index);
+ for (const key of this.#padReports.keys())
+ if (!this.#padSeen.has(key)) this.#padReports.delete(key);
+ // The pad the player last claimed owns the input, and keeps it while idle so its releases are
+ // read. A pad that disappeared, or one the browser now reports unmapped, hands over to whichever
+ // pad is being held, then to the first pad there is. A slot nobody claimed — it merely held the
+ // input when nothing else did — is not owned, so a gentle tilt of the pad in the player's hands
+ // outranks it; that is what stops an idle dongle in slot 0 from needing a claim to get past.
+ let active: number;
+ let owned = false;
+ if (claimSlot >= 0) {
+ active = claimSlot;
+ owned = true;
+ } else if (activeStillMapped && this.#activePadClaimed && this.#activePadSlot >= 0) {
+ active = this.#activePadSlot;
+ owned = true;
+ } else if (inputSlot >= 0) {
+ active = inputSlot;
+ } else {
+ active = firstMapped;
+ }
+ this.#activePadSlot = active;
+ this.#activePadClaimed = owned;
+ // Reused buffers: fresh arrays here were four allocations per update step, device or not.
+ this.#gamepadAxes.length = 0;
+ this.#gamepadButtons.length = 0;
+ const pad = active < 0 ? undefined : sources[active];
+ if (pad === null || pad === undefined || this.#readPad(pad, active) === "unmapped") {
+ this.raw.gamepad.axes = this.#gamepadAxes;
+ this.raw.gamepad.buttons = this.#gamepadButtons;
+ return;
+ }
+ for (const axis of this.#padScratch.axes) this.#gamepadAxes.push(axis);
+ for (const button of this.#padScratch.buttons) this.#gamepadButtons.push(button.pressed);
+ this.raw.gamepad.axes = this.#gamepadAxes;
+ this.raw.gamepad.buttons = this.#gamepadButtons;
+ }
+
+ /** Normalizes one pad into the scratch buffer and reports it once per connect. */
+ #readPad(pad: IInputGamepad, index: number): GamepadNormalizer {
+ const normalizer = toStandardPad(pad, this.#padScratch);
+ // `index:id`: two identical pads are two pads, and a hotplug that moves a pad to another slot
+ // is a new reading of where it is rather than a silent re-use of the old line. Built once per
+ // slot:id, because a template literal allocates and this runs twice a tick for the active pad.
+ const id = pad.id;
+ let key = this.#padKeys[index];
+ if (key === undefined || this.#padKeyIds[index] !== id) {
+ key = `${index}:${id ?? (PAD_SLOT_KEYS[index] as string)}`;
+ this.#padKeys[index] = key;
+ this.#padKeyIds[index] = id;
+ }
+ this.#padSeen.add(key);
+ if (this.#padReports.get(key) !== normalizer) {
+ this.#padReports.set(key, normalizer);
+ console.info(
+ `TN_GAMEPAD ${JSON.stringify({
+ deadZone: this.gamepadDeadZone,
+ id: pad.id ?? (PAD_SLOT_KEYS[index] as string),
+ mapping: normalizer,
+ })}`,
+ );
+ }
+ applyStickDeadZone(this.#padScratch.axes, 0, 1, this.gamepadDeadZone);
+ applyStickDeadZone(this.#padScratch.axes, 2, 3, this.gamepadDeadZone);
+ return normalizer;
+ }
+
+ /** Clears one slot's claim history, so a pad that returns to it starts from rest. */
+ #forgetPad(index: number): void {
+ this.#padButtonMask[index] = 0;
+ this.#padStick[index] = 0;
+ }
+
+ /** The scratch pad's pressed buttons as a mask, so a new press is one integer compare. */
+ #padButtonMaskNow(): number {
+ let mask = 0;
+ const buttons = this.#padScratch.buttons;
+ for (let index = 0; index < buttons.length; index += 1)
+ if (buttons[index]?.pressed === true) mask |= 1 << index;
+ return mask;
+ }
+
+ /** The scratch pad's larger stick travel on the rescaled axes, which is what a claim compares. */
+ #padStickNow(): number {
+ const axes = this.#padScratch.axes;
+ const left = Math.sqrt((axes[0] ?? 0) ** 2 + (axes[1] ?? 0) ** 2);
+ const right = Math.sqrt((axes[2] ?? 0) ** 2 + (axes[3] ?? 0) ** 2);
+ return Math.max(left, right);
+ }
+
+ /** Whether the normalized pad in the scratch buffer has moved past its dead zone or pressed anything. */
+ #padHasInput(): boolean {
+ for (const axis of this.#padScratch.axes) if (axis !== 0) return true;
+ for (const button of this.#padScratch.buttons) if (button.pressed) return true;
+ return false;
+ }
+
/** Was this action pressed at any point since the last tick, even if already released? */
#latchedPressed(name: string): boolean {
const binding = this.#bindings[name];
diff --git a/packages/create-threenative/__tests__/config.spec.ts b/packages/create-threenative/__tests__/config.spec.ts
index 9dea77c804..61049381b3 100644
--- a/packages/create-threenative/__tests__/config.spec.ts
+++ b/packages/create-threenative/__tests__/config.spec.ts
@@ -14,6 +14,7 @@ import {
MODEL_PASS_KEYS,
MODEL_QUANTIZE_KEYS,
MODEL_VIRTUAL_KEYS,
+ configTempName,
loadConfig,
} from "../src/config.js";
import { createProject, discoverTemplateNames } from "../src/index.js";
@@ -93,6 +94,39 @@ const TRNS_PNG = Buffer.from(
"base64",
);
+it("loads concurrent configs, which is how vite resolves one project's config", async () => {
+ const root = await project();
+ await config(root, 'export default { engine: "threenative" };');
+ // Loader coverage, not the lock for the temp-name collision: `vite` resolves a project's config
+ // more than once in one process (its plugin and its command, through `resolveConfig`'s
+ // `Promise.all`), and these loads must all answer. The collision itself is locked by the
+ // `configTempName` spec below — under a frozen clock this one still passes without the suffix,
+ // because the second `import()` is served from the module cache and never re-reads the deleted file.
+ const frozen = Date.now;
+ Date.now = () => 1_700_000_000_000;
+ try {
+ const loaded = await Promise.all([loadConfig(root), loadConfig(root), loadConfig(root)]);
+ expect(loaded.map((each) => each.engine)).toEqual([
+ "threenative",
+ "threenative",
+ "threenative",
+ ]);
+ } finally {
+ Date.now = frozen;
+ }
+});
+
+it("names every config temp file uniquely, even when two loads share a millisecond", () => {
+ // The suffix is the whole fix: the collision this locks was two loads picking the same path from
+ // the pid and the clock alone, so the first one's unlink deleted the second one's file. This is
+ // deterministic where the race is not — dropping the sequence again fails here every time.
+ const first = configTempName("/game", 42, 1_700_000_000_000, 1);
+ const second = configTempName("/game", 42, 1_700_000_000_000, 2);
+ expect(first).not.toBe(second);
+ expect(path.basename(first)).toBe(".threenative.config.42.1700000000000.1.mjs");
+ expect(path.basename(second)).toBe(".threenative.config.42.1700000000000.2.mjs");
+});
+
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
});
diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts
index a602d36b3d..4de165c73e 100644
--- a/packages/create-threenative/__tests__/scaffold.spec.ts
+++ b/packages/create-threenative/__tests__/scaffold.spec.ts
@@ -116,67 +116,67 @@ const PACK_GENERATED_PAGES: readonly string[] = ["capability-reference.md"];
const SCAFFOLD_TREE_HASHES: Readonly> = {
// scaffold-pin:begin
"action-rpg": {
- shared: "3dfee3757561db8b2824b23d6131e9b7308bf13998e7a4bf7b6b6e3adf6a7f53",
+ shared: "89aa333d822a6627d2ad23f96521abf4ea7ba92bcec1ed55f9c30a8ee83ff250",
kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa",
},
adventure: {
- shared: "c05c558a93c9297b490fd7d40b9340fb2fa639cbf631026df3ef86c2d14fb81b",
+ shared: "ddac540bdf78253f87db7432d1b80a2a4dc7e20809cd1006b3cc261492b7d6fa",
kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a",
},
collectathon: {
- shared: "b2f3bc054c911405e898063214a2e18c5916b08e7bce35cabfbaa6ef6f7777a7",
+ shared: "3a0df8488b8d94deb01464e10fabde2d6ac4e65647e98f39f5afb235ec71ffae",
kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72",
},
kart: {
- shared: "8a4de1bce71339bd7249b9a41e921f8e111106149ae5680f461d202d265508a8",
+ shared: "676434e30b0ed4e2dfe698e5d6ab393e47fe7779ef7df617ca8f12f1f24c7748",
kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2",
},
minimal: {
- shared: "899d2d05ab5d879a320bab1e7efd1c8735126154792c01df7fe86f0582d412c6",
+ shared: "f4b43e245b3a9810947d7a3b5652b409ffb097cafb7834ed215567f83d3379af",
kit: "29c32badb8a5c214d2284d6a2635570c03f3241feb8180cce5da681cd8222b96",
},
platformer: {
- shared: "5d295a3d2bee8ed41207d3630d07f1f325d80ce763be0c119378249b9c87ed0c",
- kit: "abddd25226bc58d65bf32d8dc38f7f10ef4af80e3ba1905290ff63bb7d25032e",
+ shared: "0720f0c8f3374f5d41e7540dd3fe16f4ceeb2b5717fe2b163bafd4d7742983a8",
+ kit: "acb3a17cf7c1371188eedeab5e7a921929c56e35461f9c18c92edd031a56393f",
},
puzzle: {
- shared: "9900dba834fb339115c9aa9e7afd0581d9935f3004b228dd654f991b11493847",
+ shared: "865623ec3097b295dfc67012b3e61eaba0d7a322e2b6ec59620478863fdbdae6",
kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401",
},
racing: {
- shared: "c8b7db38ef9b68fbdf55dead10560df29569f5c183cd0f8f4c1dbf6469071f64",
+ shared: "8463fb44077a88e0c5938db571af259c59f668eedca9d477a0bc4b70239c3c60",
kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9",
},
rain: {
- shared: "0856ecca144a6d2419c0bf4d1b26da6448d358f9fa8ebaa9b6604780442054b3",
+ shared: "f3d68ab1eb2759202e15499046cc80d362d3586be314f68610eed0bf2c20367e",
kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96",
},
rts: {
- shared: "94c096399f971506d873735610fc2759b108d672e200472279a8d0d4fcd92e09",
+ shared: "9e7c20d1b7eceb245eb9b1db1e30d1294f6533a9b69ef0f3e3bfad1c67d86b38",
kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669",
},
runner: {
- shared: "e522646cf7dc0567c1496c1abda755fb13d9bdba62d98e18cc3e1c68264db3af",
+ shared: "cd43eda17fbdc91bedb7680441daf0a30dee0f3b1e19bacebb124ab4471ff1ba",
kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158",
},
sailing: {
- shared: "e230d0ea5ae2e458c32f0ed0aa31e5c7a0bce6771adf41293623489611acd41c",
+ shared: "6e39c4c67676a2c1d4ae7fdf148a0e7cc4dde3b6207b49da84701a4266e720c0",
kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0",
},
shooter: {
- shared: "ddcf11d581b12631f2f49319a022abddf66d59f9ce83265bb2935acb8414953e",
+ shared: "dbe1eb0f2872275fc9759deea95c4c624f7fa82fb7adccab3d22a20889cabaac",
kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5",
},
snow: {
- shared: "1b360f0661b462c2872dff12b403cf072690e19434094966a84db41ef34718bb",
+ shared: "130389a38fb48663bd0cd391751ab3d2eb0c47e0d535e236b09366fd1fc93099",
kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823",
},
starter: {
- shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b",
+ shared: "000bd75177222e5b3ab03beee4118b7c63a8dd59fdc316487d61ff7b3758376d",
kit: "232c5278ee55f8581bf3b2b25c53d8a852a4a1704947ee2a6d22f3756c724a1e",
},
"tower-defense": {
- shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b",
+ shared: "119e88ddb3fdcf2f3f4c0aade6e62e4b480e2b6a1292d69552040fb6e8083470",
kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0",
},
// scaffold-pin:end
diff --git a/packages/create-threenative/agent-docs/references/conventions.md b/packages/create-threenative/agent-docs/references/conventions.md
index fc432ad414..11e6ec2f72 100644
--- a/packages/create-threenative/agent-docs/references/conventions.md
+++ b/packages/create-threenative/agent-docs/references/conventions.md
@@ -233,6 +233,13 @@ Default: none; browser-only WASM. Override: import `@threenative/physics/navigat
This kit needs no navmesh and imports none, so it runs on every target; adding `@threenative/physics/navigation` would make it web and desktop only.
+
+## Controller
+
+Default: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Reports: TN_GAMEPAD.
+
+A game binds standard indices and nothing else: `buttons: [0]` is the south face button on an Xbox pad, an 8BitDo and a Steam Deck, `buttons: [6, 7]` are the triggers, and `ctx.input.vector("move")` reads the left stick. A pad the browser reports without a mapping is normalized from a vendor:product row in `@threenative/core`; a pad nothing knows is ignored and reported once as `TN_GAMEPAD … unmapped`, never read with raw indices, because raw index 0 fires whatever action that device happened to number 0. One pad at a time is read, and it is the one the player last claimed: a button press, or a stick travelling past half tilt. A stick resting past the dead zone claims nothing, and one jittering across it claims nothing either, so a worn pad left on the couch, an idle wheel or a paired-but-idle dongle cannot take the input — at boot or in the middle of a move. The claimed pad keeps the slot while idle, which is what lets its releases be read; a pad that disappears or turns unmapped hands over. At `gamepadDeadZone: 0` a noisy stick does produce claims, which is what raw axes mean. Both sticks carry the radial dead zone: a stick resting at `0.08` reads zero, and one past the zone is rescaled so full tilt is still `1`.
+
## Move input
diff --git a/packages/create-threenative/conventions.json b/packages/create-threenative/conventions.json
index 5bec3a184d..0024fa5229 100644
--- a/packages/create-threenative/conventions.json
+++ b/packages/create-threenative/conventions.json
@@ -400,6 +400,14 @@
"marker": "—",
"kits": "all"
},
+ {
+ "id": "gamepad",
+ "name": "Controller",
+ "default": "W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read",
+ "override": "`gamepadDeadZone: 0` for raw axes",
+ "marker": "TN_GAMEPAD",
+ "kits": "all"
+ },
{
"id": "move-axis",
"name": "Move input",
diff --git a/packages/create-threenative/src/config.ts b/packages/create-threenative/src/config.ts
index 386c651b67..71882b83c4 100644
--- a/packages/create-threenative/src/config.ts
+++ b/packages/create-threenative/src/config.ts
@@ -437,6 +437,19 @@ function resolveEsbuild(cwd: string): string | undefined {
}
}
+/** Distinguishes config loads that land in the same millisecond within one process. */
+let configLoadSequence = 0;
+
+/**
+ * The temp file a transpiled config is imported from. `sequence` is what stops two loads in the same
+ * millisecond, in one process, from picking the same path — `vite` resolves a project's config more
+ * than once through `resolveConfig`'s `Promise.all`, and the first load's `unlink` used to delete the
+ * file the second was about to import (`TN_CONFIG_LOAD_FAILED: Cannot find module …`).
+ */
+export function configTempName(cwd: string, pid: number, now: number, sequence: number): string {
+ return path.join(cwd, `.threenative.config.${pid}.${now}.${sequence}.mjs`);
+}
+
async function importConfig(cwd: string): Promise | undefined> {
const sourcePath = path.join(cwd, CONFIG_FILE);
try {
@@ -503,7 +516,12 @@ async function importConfig(cwd: string): Promise | unde
);
}
- const compiledPath = path.join(cwd, `.threenative.config.${process.pid}.${Date.now()}.mjs`);
+ // A counter as well as the timestamp: `vite` resolves its config more than once in one process
+ // (the plugin and the command, through `resolveConfig`'s `Promise.all`), and two loads in the same
+ // millisecond used to pick the same name, so the first one's `unlink` deleted the second one's file
+ // — a flaky `TN_CONFIG_LOAD_FAILED: Cannot find module ... .threenative.config.*.mjs`.
+ configLoadSequence += 1;
+ const compiledPath = configTempName(cwd, process.pid, Date.now(), configLoadSequence);
try {
await writeFile(compiledPath, code, "utf8");
} catch (error) {
diff --git a/packages/create-threenative/templates/action-rpg/AGENTS.md b/packages/create-threenative/templates/action-rpg/AGENTS.md
index b018fded10..0bace4d779 100644
--- a/packages/create-threenative/templates/action-rpg/AGENTS.md
+++ b/packages/create-threenative/templates/action-rpg/AGENTS.md
@@ -96,6 +96,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/action-rpg/CLAUDE.md b/packages/create-threenative/templates/action-rpg/CLAUDE.md
index 23eae156a0..23e6a15b97 100644
--- a/packages/create-threenative/templates/action-rpg/CLAUDE.md
+++ b/packages/create-threenative/templates/action-rpg/CLAUDE.md
@@ -98,6 +98,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/adventure/AGENTS.md b/packages/create-threenative/templates/adventure/AGENTS.md
index bb4bedce41..66342a9e12 100644
--- a/packages/create-threenative/templates/adventure/AGENTS.md
+++ b/packages/create-threenative/templates/adventure/AGENTS.md
@@ -89,6 +89,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/adventure/CLAUDE.md b/packages/create-threenative/templates/adventure/CLAUDE.md
index 530b561834..85c46601bf 100644
--- a/packages/create-threenative/templates/adventure/CLAUDE.md
+++ b/packages/create-threenative/templates/adventure/CLAUDE.md
@@ -91,6 +91,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/collectathon/AGENTS.md b/packages/create-threenative/templates/collectathon/AGENTS.md
index 8b8a42d83c..c18e6e9d37 100644
--- a/packages/create-threenative/templates/collectathon/AGENTS.md
+++ b/packages/create-threenative/templates/collectathon/AGENTS.md
@@ -90,6 +90,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/collectathon/CLAUDE.md b/packages/create-threenative/templates/collectathon/CLAUDE.md
index ff11b1b694..8e0807ad46 100644
--- a/packages/create-threenative/templates/collectathon/CLAUDE.md
+++ b/packages/create-threenative/templates/collectathon/CLAUDE.md
@@ -92,6 +92,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/kart/AGENTS.md b/packages/create-threenative/templates/kart/AGENTS.md
index 0d3c9a4e3c..e34ddb0acd 100644
--- a/packages/create-threenative/templates/kart/AGENTS.md
+++ b/packages/create-threenative/templates/kart/AGENTS.md
@@ -85,6 +85,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
- **Engine** `#engine`: owned C++ engine. Override: `engine: "threejs"` only when the user asks. Marker: `TN_NATIVE_ENGINE_UNBOUND`.
diff --git a/packages/create-threenative/templates/kart/CLAUDE.md b/packages/create-threenative/templates/kart/CLAUDE.md
index 5e136562f0..2b4f596fdd 100644
--- a/packages/create-threenative/templates/kart/CLAUDE.md
+++ b/packages/create-threenative/templates/kart/CLAUDE.md
@@ -87,6 +87,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
- **Engine** `#engine`: owned C++ engine. Override: `engine: "threejs"` only when the user asks. Marker: `TN_NATIVE_ENGINE_UNBOUND`.
diff --git a/packages/create-threenative/templates/minimal/AGENTS.md b/packages/create-threenative/templates/minimal/AGENTS.md
index 28fa3843ce..776885dae6 100644
--- a/packages/create-threenative/templates/minimal/AGENTS.md
+++ b/packages/create-threenative/templates/minimal/AGENTS.md
@@ -95,6 +95,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/minimal/CLAUDE.md b/packages/create-threenative/templates/minimal/CLAUDE.md
index 1ab7af366b..f52e3b3f24 100644
--- a/packages/create-threenative/templates/minimal/CLAUDE.md
+++ b/packages/create-threenative/templates/minimal/CLAUDE.md
@@ -97,6 +97,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/platformer/AGENTS.md b/packages/create-threenative/templates/platformer/AGENTS.md
index 9b566d3336..c79c2f2cb8 100644
--- a/packages/create-threenative/templates/platformer/AGENTS.md
+++ b/packages/create-threenative/templates/platformer/AGENTS.md
@@ -38,7 +38,7 @@ A change to an existing game repeats steps 2-3 for that change, then steps 5-8.
Source in `src/`: `entities/`, `game.ts`, `level/`, `main.ts`, `playtest-events.ts`, `render/`, `scenes/`, `state.ts`, `ui/`, `worker-page.ts`, `worker.ts`.
-18 scenarios in `playtests/`; `playtests/survives.playtest.json` is the smoke proof.
+20 scenarios in `playtests/`; `playtests/survives.playtest.json` is the smoke proof.
Exposure policy: `src/render/exposure.ts` (`node_modules/create-threenative/agent-docs/references/auto-exposure.md`).
Kit notes: `node_modules/create-threenative/agent-docs/references/kit-platformer.md`.
@@ -92,6 +92,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/platformer/CLAUDE.md b/packages/create-threenative/templates/platformer/CLAUDE.md
index 37185bf018..84c29f4e7d 100644
--- a/packages/create-threenative/templates/platformer/CLAUDE.md
+++ b/packages/create-threenative/templates/platformer/CLAUDE.md
@@ -40,7 +40,7 @@ A change to an existing game repeats steps 2-3 for that change, then steps 5-8.
Source in `src/`: `entities/`, `game.ts`, `level/`, `main.ts`, `playtest-events.ts`, `render/`, `scenes/`, `state.ts`, `ui/`, `worker-page.ts`, `worker.ts`.
-18 scenarios in `playtests/`; `playtests/survives.playtest.json` is the smoke proof.
+20 scenarios in `playtests/`; `playtests/survives.playtest.json` is the smoke proof.
Exposure policy: `src/render/exposure.ts` (`node_modules/create-threenative/agent-docs/references/auto-exposure.md`).
Kit notes: `node_modules/create-threenative/agent-docs/references/kit-platformer.md`.
@@ -94,6 +94,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/platformer/playtests/gamepad-idle.playtest.json b/packages/create-threenative/templates/platformer/playtests/gamepad-idle.playtest.json
new file mode 100644
index 0000000000..ac11d5816a
--- /dev/null
+++ b/packages/create-threenative/templates/platformer/playtests/gamepad-idle.playtest.json
@@ -0,0 +1,21 @@
+{
+ "name": "platformer-gamepad-idle",
+ "schemaVersion": 1,
+ "target": "web",
+ "viewport": { "width": 1280, "height": 720 },
+ "warmupFrames": 20,
+ "subject": "player",
+ "steps": [
+ {
+ "kind": "input",
+ "label": "stick resting at 0.1",
+ "gamepad": { "axes": [0.1, 0] },
+ "waitTicks": 180,
+ "release": true
+ }
+ ],
+ "assert": {
+ "diagnostics": { "noConsoleErrors": true, "runtimeReady": true },
+ "movement": { "entity": "player", "maxDistance": 0.5 }
+ }
+}
diff --git a/packages/create-threenative/templates/platformer/playtests/gamepad.playtest.json b/packages/create-threenative/templates/platformer/playtests/gamepad.playtest.json
new file mode 100644
index 0000000000..9c92324128
--- /dev/null
+++ b/packages/create-threenative/templates/platformer/playtests/gamepad.playtest.json
@@ -0,0 +1,30 @@
+{
+ "name": "platformer-gamepad",
+ "schemaVersion": 1,
+ "target": "web",
+ "viewport": { "width": 1280, "height": 720 },
+ "warmupFrames": 20,
+ "subject": "player",
+ "steps": [
+ {
+ "kind": "input",
+ "label": "stick right",
+ "gamepad": { "axes": [1, 0] },
+ "waitTicks": 180,
+ "release": true
+ },
+ {
+ "kind": "input",
+ "label": "south button",
+ "gamepad": { "buttons": [0] },
+ "waitTicks": 120,
+ "release": true
+ },
+ { "kind": "wait", "label": "settle", "waitFrames": 40, "release": true }
+ ],
+ "assert": {
+ "diagnostics": { "noConsoleErrors": true, "runtimeReady": true },
+ "movement": { "entity": "player", "minAxisDelta": { "axis": "x", "min": 3 } },
+ "resources": [{ "changed": true, "gte": 1, "id": "state", "path": "jumps" }]
+ }
+}
diff --git a/packages/create-threenative/templates/puzzle/AGENTS.md b/packages/create-threenative/templates/puzzle/AGENTS.md
index 7ab73a4e71..16f9a7db85 100644
--- a/packages/create-threenative/templates/puzzle/AGENTS.md
+++ b/packages/create-threenative/templates/puzzle/AGENTS.md
@@ -91,6 +91,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/puzzle/CLAUDE.md b/packages/create-threenative/templates/puzzle/CLAUDE.md
index 3823382355..f3a1c39491 100644
--- a/packages/create-threenative/templates/puzzle/CLAUDE.md
+++ b/packages/create-threenative/templates/puzzle/CLAUDE.md
@@ -93,6 +93,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/racing/AGENTS.md b/packages/create-threenative/templates/racing/AGENTS.md
index e7ad37d75c..8b2a38b01a 100644
--- a/packages/create-threenative/templates/racing/AGENTS.md
+++ b/packages/create-threenative/templates/racing/AGENTS.md
@@ -93,6 +93,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/racing/CLAUDE.md b/packages/create-threenative/templates/racing/CLAUDE.md
index 57d39f1a6c..bc1a7c8789 100644
--- a/packages/create-threenative/templates/racing/CLAUDE.md
+++ b/packages/create-threenative/templates/racing/CLAUDE.md
@@ -95,6 +95,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/rain/AGENTS.md b/packages/create-threenative/templates/rain/AGENTS.md
index 221d69b585..41942ae895 100644
--- a/packages/create-threenative/templates/rain/AGENTS.md
+++ b/packages/create-threenative/templates/rain/AGENTS.md
@@ -88,6 +88,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/rain/CLAUDE.md b/packages/create-threenative/templates/rain/CLAUDE.md
index 55ac6476c5..af68403b02 100644
--- a/packages/create-threenative/templates/rain/CLAUDE.md
+++ b/packages/create-threenative/templates/rain/CLAUDE.md
@@ -90,6 +90,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/rts/AGENTS.md b/packages/create-threenative/templates/rts/AGENTS.md
index eafa51ada6..c1754fd177 100644
--- a/packages/create-threenative/templates/rts/AGENTS.md
+++ b/packages/create-threenative/templates/rts/AGENTS.md
@@ -90,6 +90,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/rts/CLAUDE.md b/packages/create-threenative/templates/rts/CLAUDE.md
index 55bf5f771e..4fdd025aab 100644
--- a/packages/create-threenative/templates/rts/CLAUDE.md
+++ b/packages/create-threenative/templates/rts/CLAUDE.md
@@ -92,6 +92,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/runner/AGENTS.md b/packages/create-threenative/templates/runner/AGENTS.md
index 675911244d..e3007b6416 100644
--- a/packages/create-threenative/templates/runner/AGENTS.md
+++ b/packages/create-threenative/templates/runner/AGENTS.md
@@ -92,6 +92,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/runner/CLAUDE.md b/packages/create-threenative/templates/runner/CLAUDE.md
index 2acc11a7a3..378c834145 100644
--- a/packages/create-threenative/templates/runner/CLAUDE.md
+++ b/packages/create-threenative/templates/runner/CLAUDE.md
@@ -94,6 +94,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/sailing/AGENTS.md b/packages/create-threenative/templates/sailing/AGENTS.md
index acaac38cf5..e7fff09b15 100644
--- a/packages/create-threenative/templates/sailing/AGENTS.md
+++ b/packages/create-threenative/templates/sailing/AGENTS.md
@@ -93,6 +93,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/sailing/CLAUDE.md b/packages/create-threenative/templates/sailing/CLAUDE.md
index 5c9d94a8d3..57dcd2699b 100644
--- a/packages/create-threenative/templates/sailing/CLAUDE.md
+++ b/packages/create-threenative/templates/sailing/CLAUDE.md
@@ -95,6 +95,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/shooter/AGENTS.md b/packages/create-threenative/templates/shooter/AGENTS.md
index 10320694ee..1f1c7b3f4d 100644
--- a/packages/create-threenative/templates/shooter/AGENTS.md
+++ b/packages/create-threenative/templates/shooter/AGENTS.md
@@ -94,6 +94,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/shooter/CLAUDE.md b/packages/create-threenative/templates/shooter/CLAUDE.md
index 63dda8e01a..4eda0443ee 100644
--- a/packages/create-threenative/templates/shooter/CLAUDE.md
+++ b/packages/create-threenative/templates/shooter/CLAUDE.md
@@ -96,6 +96,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/snow/AGENTS.md b/packages/create-threenative/templates/snow/AGENTS.md
index 889aa72a12..c5e4bc86a4 100644
--- a/packages/create-threenative/templates/snow/AGENTS.md
+++ b/packages/create-threenative/templates/snow/AGENTS.md
@@ -92,6 +92,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/snow/CLAUDE.md b/packages/create-threenative/templates/snow/CLAUDE.md
index 5c55287ceb..2a9bccfb4f 100644
--- a/packages/create-threenative/templates/snow/CLAUDE.md
+++ b/packages/create-threenative/templates/snow/CLAUDE.md
@@ -94,6 +94,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/starter/AGENTS.md b/packages/create-threenative/templates/starter/AGENTS.md
index f18820f086..7be39d4b76 100644
--- a/packages/create-threenative/templates/starter/AGENTS.md
+++ b/packages/create-threenative/templates/starter/AGENTS.md
@@ -97,6 +97,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/starter/CLAUDE.md b/packages/create-threenative/templates/starter/CLAUDE.md
index 312716a971..28ee62bc5b 100644
--- a/packages/create-threenative/templates/starter/CLAUDE.md
+++ b/packages/create-threenative/templates/starter/CLAUDE.md
@@ -99,6 +99,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/tower-defense/AGENTS.md b/packages/create-threenative/templates/tower-defense/AGENTS.md
index 047561f07d..d962b77097 100644
--- a/packages/create-threenative/templates/tower-defense/AGENTS.md
+++ b/packages/create-threenative/templates/tower-defense/AGENTS.md
@@ -91,6 +91,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/create-threenative/templates/tower-defense/CLAUDE.md b/packages/create-threenative/templates/tower-defense/CLAUDE.md
index 0b06d83c94..28c5e2f7a5 100644
--- a/packages/create-threenative/templates/tower-defense/CLAUDE.md
+++ b/packages/create-threenative/templates/tower-defense/CLAUDE.md
@@ -93,6 +93,7 @@ Each is on by default with a named override. Detail: `node_modules/create-threen
- **CPU priority** `#cpu-priority`: game threads first. Override: `display.priority: "normal"`. Marker: `TN_PROCESS_PRIORITY`.
- **Terrain** `#terrain`: authored in the game. Override: optional `@threenative/terrain`; `references/terrain-authoring.md`.
- **Navigation** `#navigation`: none; browser-only WASM. Override: import `@threenative/physics/navigation` on purpose.
+- **Controller** `#gamepad`: W3C standard mapping on every pad, radial dead zone `0.2`, and a button press or a decisive stick move claims which pad is read. Override: `gamepadDeadZone: 0` for raw axes. Marker: `TN_GAMEPAD`.
- **Move input** `#move-axis`: `input.vector("move").y` is +up. Override: one explicit `-move.y` conversion.
- **Scene change** `#scene-change`: `ctx.goto(name)` keeps state. Override: `game.goto(name)` resets state.
- **Randomness** `#seed`: unseeded. Override: `defineGame({ seed })`.
diff --git a/packages/engine-mcp/__tests__/search.spec.ts b/packages/engine-mcp/__tests__/search.spec.ts
index 37ab56f133..d9c7402ef4 100644
--- a/packages/engine-mcp/__tests__/search.spec.ts
+++ b/packages/engine-mcp/__tests__/search.spec.ts
@@ -37,6 +37,11 @@ describe("threenative-engine-mcp", () => {
// `tak` and `hav`, which the raw set never matched, so they survived the post-stem filter.
expect(capabilitySituationTokens("makes gives takes having")).toEqual([]);
});
+ it("leads an Xbox, Steam Deck or stick-drift request with the controller map (PRD-880)", () => {
+ for (const situation of ["xbox controller", "steam deck", "stick drift"]) {
+ expect(searchResults(situation).map((result) => result.symbol)[0]).toBe("InputMap");
+ }
+ });
it("finds the traced probe field by its Unreal name (PRD-603)", () => {
expect(searchResults("lumen radiance cache").map((r) => r.symbol)[0]).toBe("RadianceCache");
expect(
diff --git a/packages/playtest/__tests__/gamepad-step.spec.ts b/packages/playtest/__tests__/gamepad-step.spec.ts
new file mode 100644
index 0000000000..2426ef5ab2
--- /dev/null
+++ b/packages/playtest/__tests__/gamepad-step.spec.ts
@@ -0,0 +1,101 @@
+import { test, expect } from "vitest";
+import type { Page } from "playwright";
+
+import { validateStep } from "../src/scenario/schema-validate.js";
+import { installScriptedGamepad } from "../src/runner/browserSession.js";
+import { runStep, setPlaytestGamepad } from "../src/runner/steps.js";
+
+test("a gamepad step injects the standard pad a scenario scripts, and release centres it", async () => {
+ const injected: unknown[] = [];
+ const page = {
+ evaluate: async (_fn: unknown, state: unknown) => {
+ injected.push(state);
+ },
+ keyboard: { down: async () => undefined, up: async () => undefined },
+ mouse: { move: async () => undefined },
+ } as unknown as Page;
+
+ await runStep(
+ page,
+ undefined,
+ { gamepad: { axes: [1, 0], buttons: [0] }, kind: "input", release: true } as never,
+ { height: 360, width: 640 },
+ undefined,
+ [],
+ { heldKeys: new Set(), pointerButtons: 0, pointers: new Map() },
+ undefined,
+ true,
+ );
+
+ expect(injected.filter((value) => typeof value === "object" && value !== null)).toEqual([
+ { axes: [1, 0], buttons: [0] },
+ { axes: [], buttons: [] },
+ ]);
+});
+
+test("a gamepad step validates only the standard's own axes and buttons", () => {
+ const validate = (gamepad: unknown) =>
+ validateStep({ gamepad, kind: "input", waitTicks: 1 }, "gamepad.playtest.json", 0);
+
+ expect(validate({ axes: [1, 0], buttons: [0, 7] }).gamepad).toEqual({ axes: [1, 0], buttons: [0, 7] });
+ expect(() => validate({ axes: [0, 0, 0, 0, 0] })).toThrow(/gamepad must define axes/);
+ expect(() => validate({ axes: [2] })).toThrow(/gamepad must define axes/);
+ expect(() => validate({ buttons: [16] })).toThrow(/gamepad must define axes/);
+ expect(() => validate({ buttons: [1, 1] })).toThrow(/gamepad must define axes/);
+ expect(() => validate({ trigger: [0] })).toThrow(/unknown key/i);
+ expect(() => validate({})).toThrow(/gamepad must define axes/);
+});
+
+test("the injected pad is what the page's getGamepads returns, and the browser's own when unset", async () => {
+ const scripts: Array<() => void> = [];
+ const page = {
+ addInitScript: async (fn: () => void) => {
+ scripts.push(fn);
+ },
+ } as unknown as Page;
+
+ // Running the runner's own init script against a fake navigator is what proves the game sees the
+ // pad through the API it ships with, rather than through a hook only the runner knows about.
+ const navigatorHost = { getGamepads: () => [{ id: "browser-pad" }] } as {
+ getGamepads: () => Array>;
+ };
+ const saved = Object.getOwnPropertyDescriptor(globalThis, "navigator");
+ Object.defineProperty(globalThis, "navigator", { configurable: true, value: navigatorHost });
+ (globalThis as { __tnPlaytestGamepad?: unknown }).__tnPlaytestGamepad = undefined;
+ try {
+ await installScriptedGamepad(page);
+ scripts[0]?.();
+ expect(navigatorHost.getGamepads()).toEqual([{ id: "browser-pad" }]);
+
+ (globalThis as { __tnPlaytestGamepad?: unknown }).__tnPlaytestGamepad = {
+ axes: [1, 0],
+ buttons: [0],
+ };
+ const [pad] = navigatorHost.getGamepads() as Array<{
+ axes: number[];
+ buttons: Array<{ pressed: boolean }>;
+ mapping: string;
+ }>;
+ expect(pad?.mapping).toBe("standard");
+ expect(pad?.axes).toEqual([1, 0, 0, 0]);
+ expect(pad?.buttons).toHaveLength(16);
+ expect(pad?.buttons[0]?.pressed).toBe(true);
+ expect(pad?.buttons[1]?.pressed).toBe(false);
+ } finally {
+ if (saved === undefined) {
+ (globalThis as { navigator?: unknown }).navigator = undefined;
+ } else {
+ Object.defineProperty(globalThis, "navigator", saved);
+ }
+ (globalThis as { __tnPlaytestGamepad?: unknown }).__tnPlaytestGamepad = undefined;
+ }
+});
+
+test("setPlaytestGamepad hands the pad to the page as plain data", async () => {
+ const calls: unknown[] = [];
+ await setPlaytestGamepad(
+ { evaluate: async (_fn: unknown, state: unknown) => void calls.push(state) } as unknown as Page,
+ { buttons: [7] },
+ );
+ expect(calls).toEqual([{ buttons: [7] }]);
+});
diff --git a/packages/playtest/src/index.ts b/packages/playtest/src/index.ts
index 30ccb45d42..5ea0312c7b 100644
--- a/packages/playtest/src/index.ts
+++ b/packages/playtest/src/index.ts
@@ -193,6 +193,7 @@ export { rejectUnknownKeys } from "./scenario.js";
export type {
IPlaytestArtifactRequest,
IPlaytestPathAssertion,
+ IPlaytestGamepad,
IPlaytestFramebufferCoverageAssertion,
IPlaytestPerformanceAssertion,
IPlaytestRenderChainAssertion,
diff --git a/packages/playtest/src/runner/browserSession.ts b/packages/playtest/src/runner/browserSession.ts
index 0fd671ba6e..b2a02b879a 100644
--- a/packages/playtest/src/runner/browserSession.ts
+++ b/packages/playtest/src/runner/browserSession.ts
@@ -53,10 +53,58 @@ export function openRunnerPage(
// This copy is navigation-only. Reports and operator diagnostics retain the URL supplied on
// the command line, while Android Chrome reaches the same server through its reverse tunnel.
return installBootFailure(page, scenario)
+ // Only a scenario that scripts a pad pays for the override: every other page keeps the browser's
+ // own `navigator.getGamepads`, untouched.
+ .then(() =>
+ scenario.steps.some((step) => step.gamepad !== undefined)
+ ? installScriptedGamepad(page)
+ : undefined,
+ )
.then(() => (scenario.reducedMotion === undefined ? undefined : page.emulateMedia({ reducedMotion: scenario.reducedMotion })))
.then(() => openPageAndConnectBridge(page, { ...config, url: navigationUrl }, scenario));
}
+/**
+ * `navigator.getGamepads` answers the pad a `gamepad` step scripts, and the browser's own pads when
+ * no step scripts one. Installed before navigation because a game samples pads from its first frame;
+ * a plain object is enough, since a game reads `axes`, `buttons[i].pressed`, `id` and `mapping`.
+ */
+export async function installScriptedGamepad(page: Page): Promise {
+ await page.addInitScript(() => {
+ const KEY = "tn-playtest-gamepad";
+ const native = navigator.getGamepads?.bind(navigator);
+ const scripted = () => {
+ // Session storage, not a global: a scenario's step survives the page reload a startup retry
+ // performs, and still belongs to this tab alone.
+ const stored = globalThis.sessionStorage?.getItem(KEY);
+ const state = stored === null || stored === undefined
+ ? (globalThis as { __tnPlaytestGamepad?: { axes?: number[]; buttons?: number[] } }).__tnPlaytestGamepad
+ : (JSON.parse(stored) as { axes?: number[]; buttons?: number[] });
+ if (state === undefined) return native ? native() : [];
+ const held = new Set(state.buttons ?? []);
+ const axes = state.axes ?? [];
+ const buttons = [];
+ for (let index = 0; index < 16; index += 1) {
+ const pressed = held.has(index);
+ buttons.push({ pressed, touched: pressed, value: pressed ? 1 : 0 });
+ }
+ return [{
+ axes: [axes[0] ?? 0, axes[1] ?? 0, axes[2] ?? 0, axes[3] ?? 0],
+ buttons,
+ connected: true,
+ id: "TN Playtest Pad (STANDARD GAMEPAD Vendor: 045e Product: 028e)",
+ index: 0,
+ mapping: "standard",
+ timestamp: performance.now(),
+ }];
+ };
+ Object.defineProperty(navigator, "getGamepads", {
+ configurable: true,
+ value: scripted,
+ });
+ });
+}
+
async function installBootFailure(page: Page, scenario: IPlaytestScenario): Promise {
if (scenario.bootFailure !== "renderer-no-adapter") return;
await page.addInitScript(() => {
diff --git a/packages/playtest/src/runner/deviceRunner.ts b/packages/playtest/src/runner/deviceRunner.ts
index 6355b16bd4..76b6f1f31c 100644
--- a/packages/playtest/src/runner/deviceRunner.ts
+++ b/packages/playtest/src/runner/deviceRunner.ts
@@ -45,7 +45,7 @@ import { waitForResource } from "./wait-for-resource.js";
import type { IStandalonePlaytestConfig } from "./config.js";
import { DeviceMetricsRecorder } from "./deviceMetrics.js";
import {
- androidMailboxPaths,
+ type androidMailboxPaths,
DeviceBridgeTransport,
DeviceMailboxTransport,
deviceTimeoutDiagnostic,
@@ -164,6 +164,16 @@ async function runDevicePlaytestInternal(
target.name,
), target.name);
}
+ // A scripted pad is installed by the browser runner's `navigator.getGamepads`. These lanes drive a
+ // real host process with no such seam, so running the scenario here would report green while the
+ // game never saw a controller.
+ if (scenario.steps.some((step) => step.gamepad !== undefined)) {
+ return failureReport(config, scenario, unsupportedDiagnostic(
+ "a scripted gamepad",
+ "Run this scenario on --target browser, which installs navigator.getGamepads, or drive the pad by hand.",
+ target.name,
+ ), target.name);
+ }
if (
target.name === "android"
&& scenario.assert?.framebufferCoverage !== undefined
diff --git a/packages/playtest/src/runner/steps.ts b/packages/playtest/src/runner/steps.ts
index d21da57e02..fae7d2bb54 100644
--- a/packages/playtest/src/runner/steps.ts
+++ b/packages/playtest/src/runner/steps.ts
@@ -10,6 +10,7 @@ import {
playtestStepWaitTicks,
type IPlaytestAssertionResult,
type IPlaytestFootSlipObservation,
+ type IPlaytestGamepad,
type IPlaytestSightLineObservation,
type IPlaytestObservationSnapshot,
type IPlaytestObservations,
@@ -355,6 +356,9 @@ export async function runStep(
// transport event reach the game's listener before deterministic ticks sample input.
await waitFrames(page, 1);
}
+ if (step.gamepad !== undefined) {
+ await setPlaytestGamepad(page, step.gamepad);
+ }
const press = step.press;
if (typeof press === "string") {
await page.keyboard.down(press);
@@ -455,6 +459,19 @@ export async function runStep(
afterStep = await bridge.sample(movementSampleRequest);
}
}
+ if (step.gamepad !== undefined && step.release) {
+ // A released pad is still connected and centred: a game reading `navigator.getGamepads` sees the
+ // same device with nothing held, so a released button latches as `justReleased`, not as unplugged.
+ await setPlaytestGamepad(page, { buttons: [], axes: [] });
+ if (bridge?.description.capabilities.includes("runtime.fixedStep") === true) {
+ await advanceFixedStep(page, bridge, 1, false, draw);
+ } else {
+ await waitFrames(page, 1);
+ }
+ if (bridge !== undefined && movementSampleRequest !== undefined) {
+ afterStep = await bridge.sample(movementSampleRequest);
+ }
+ }
return { afterInput, afterStep, inputDriven };
}
@@ -677,9 +694,29 @@ export function playtestStepDrivesMovement(
const hasNewInput = typeof step.press === "string"
? step.press.length > 0
: (step.press?.length ?? 0) > 0
+ || step.gamepad !== undefined
|| step.pointerPosition !== undefined
|| (step.pointers?.length ?? 0) > 0;
- return hasNewInput || (step.press === undefined && step.pointers === undefined && step.pointerPosition === undefined && hasHeldInput);
+ return hasNewInput || (step.press === undefined && step.gamepad === undefined && step.pointers === undefined && step.pointerPosition === undefined && hasHeldInput);
+}
+
+/**
+ * Points the page's scripted pad at `state`. The override that reads it is installed before
+ * navigation (`browserSession.ts`), because a game samples `navigator.getGamepads` from its first
+ * frame; a scenario without a `gamepad` step never calls this and keeps the browser's own pads.
+ */
+export async function setPlaytestGamepad(
+ page: Pick,
+ state: IPlaytestGamepad,
+): Promise {
+ await page.evaluate((next) => {
+ // A startup retry reloads the page, which wipes a global; session storage survives it.
+ globalThis.sessionStorage?.setItem("tn-playtest-gamepad", JSON.stringify(next));
+ (globalThis as { __tnPlaytestGamepad?: unknown }).__tnPlaytestGamepad = next;
+ // A game that forwards pads to a worker starts polling on `gamepadconnected`, exactly as the
+ // browser announces a real pad; without this the worker's map never samples one.
+ globalThis.dispatchEvent(new Event("gamepadconnected"));
+ }, state);
}
export function inputActive(inputState: StepInputState): boolean {
diff --git a/packages/playtest/src/scenario/schema-base.ts b/packages/playtest/src/scenario/schema-base.ts
index e4449627ff..cded5cd9c3 100644
--- a/packages/playtest/src/scenario/schema-base.ts
+++ b/packages/playtest/src/scenario/schema-base.ts
@@ -44,8 +44,25 @@ export type IPlaytestResourceWait =
| { equals?: never; gte: number; id: string; lte?: never; path: string }
| { equals?: never; gte?: never; id: string; lte: number; path: string };
+/**
+ * The standard pad a `gamepad` step holds down: the sticks' four axes and the indices held.
+ */
+export interface IPlaytestGamepad {
+ /** The standard's stick axes 0..3, each `-1`..`1`. Absent axes read centred. */
+ readonly axes?: readonly number[];
+ /** Standard button indices held down, `0` south through `15` d-pad right. */
+ readonly buttons?: readonly number[];
+}
+
export interface IPlaytestStep {
kind?: "aimAt" | "click" | "input" | "lifecycle" | "wait";
+ /**
+ * A scripted W3C "standard" pad, so a scenario proves a controller binding without hardware. The
+ * runner installs `navigator.getGamepads` on the page and this is the pad it returns: `axes` are
+ * the standard's stick axes 0..3, `buttons` the indices held down. A step that names it drives
+ * input exactly like `press` does, and `release` lifts every button and re-centres both sticks.
+ */
+ gamepad?: IPlaytestGamepad;
/** @deprecated Use holdTicks. Fixed-step bridges treat this as a tick alias. */
holdFrames?: number;
holdTicks?: number;
diff --git a/packages/playtest/src/scenario/schema-validate.ts b/packages/playtest/src/scenario/schema-validate.ts
index 5a325d71fe..e58e60b17f 100644
--- a/packages/playtest/src/scenario/schema-validate.ts
+++ b/packages/playtest/src/scenario/schema-validate.ts
@@ -503,6 +503,7 @@ export function validateSetupEntity(value: unknown, scenarioPath: string, index:
/** Keys one step accepts; exported for the documentation-drift gate alongside {@link PLAYTEST_ROOT_KEYS}. */
export const PLAYTEST_STEP_KEYS = [
"at",
+ "gamepad",
"holdFrames",
"holdTicks",
"kind",
@@ -640,6 +641,7 @@ export function validateStep(value: unknown, scenarioPath: string, index: number
rejectUnknownKeys(value.wheel, ["deltaX", "deltaY", "x", "y"], scenarioPath, `steps[${index}].wheel`);
}
const media = validateMedia(value.media, scenarioPath, index);
+ const gamepad = validateGamepad(value.gamepad, scenarioPath, index);
if (pointers !== undefined && new Set(pointers.map(({ id }) => id)).size !== pointers.length) {
throw invalidStep(scenarioPath, `Scenario step ${index} pointers must use unique ids.`);
}
@@ -744,6 +746,12 @@ export function validateStep(value: unknown, scenarioPath: string, index: number
if (value.wheel !== undefined && wheel === undefined) {
throw invalidStep(scenarioPath, `Scenario step ${index} wheel must define finite deltaY, optional finite deltaX, and optionally both normalized x and y from 0 through 1.`);
}
+ if (value.gamepad !== undefined && gamepad === undefined) {
+ throw invalidStep(scenarioPath, `Scenario step ${index} gamepad must define axes (up to 4 numbers from -1 through 1) and/or unique button indices from 0 through 15.`);
+ }
+ if (gamepad !== undefined && kind === "lifecycle") {
+ throw invalidStep(scenarioPath, `Scenario step ${index} with kind 'lifecycle' cannot define gamepad; a lifecycle step is one device operation and nothing else.`);
+ }
if (value.screenshot !== undefined && screenshot === undefined) {
throw invalidStep(scenarioPath, `Scenario step ${index} screenshot must be a stable file-safe name.`);
}
@@ -793,8 +801,8 @@ export function validateStep(value: unknown, scenarioPath: string, index: number
}
}
}
- if (at === undefined && press === undefined && overlayMessage === undefined && pointerPosition === undefined && pointers === undefined && wheel === undefined && media === undefined && window === undefined && waitFrames === undefined && waitTicks === undefined && target === undefined && waitForResource === undefined && lifecycle === undefined) {
- throw invalidStep(scenarioPath, `Scenario step ${index} must define click at, press, overlayMessage, pointerPosition, pointers, wheel, media, window, aimAt target, lifecycle operation, or waitFrames/waitTicks.`);
+ if (at === undefined && press === undefined && gamepad === undefined && overlayMessage === undefined && pointerPosition === undefined && pointers === undefined && wheel === undefined && media === undefined && window === undefined && waitFrames === undefined && waitTicks === undefined && target === undefined && waitForResource === undefined && lifecycle === undefined) {
+ throw invalidStep(scenarioPath, `Scenario step ${index} must define click at, press, gamepad, overlayMessage, pointerPosition, pointers, wheel, media, window, aimAt target, lifecycle operation, or waitFrames/waitTicks.`);
}
if (value.holdFrames !== undefined && holdFrames === undefined) {
throw invalidStep(scenarioPath, `Scenario step ${index} holdFrames must be a positive integer.`);
@@ -822,6 +830,7 @@ export function validateStep(value: unknown, scenarioPath: string, index: number
}
return {
...(kind === undefined ? {} : { kind }),
+ ...(gamepad === undefined ? {} : { gamepad }),
...(holdFrames === undefined ? {} : { holdFrames }),
...(holdTicks === undefined ? {} : { holdTicks }),
...(typeof value.label === "string" ? { label: value.label } : {}),
@@ -866,6 +875,47 @@ function validateMedia(value: unknown, scenarioPath: string, index: number): IPl
};
}
+/**
+ * A `gamepad` step's held pad: at most the standard's 4 stick axes and its 16 buttons, and nothing
+ * this runner would then have to interpret. Bounded here so a typo fails the scenario, not the run.
+ */
+function validateGamepad(value: unknown, scenarioPath: string, index: number): IPlaytestStep["gamepad"] {
+ if (value === undefined) return undefined;
+ if (!isRecord(value)) {
+ throw invalidStep(scenarioPath, `Scenario step ${index} gamepad must be an object.`);
+ }
+ rejectUnknownKeys(value, ["axes", "buttons"], scenarioPath, `steps[${index}].gamepad`);
+ const axes = gamepadAxes(value.axes);
+ const buttons = gamepadButtons(value.buttons);
+ // Any malformed half throws the caller's one message, so the scenario never runs with a pad it
+ // silently trimmed: `axes.length > 4` or a repeated button would otherwise test something else.
+ if (value.axes !== undefined && (axes === undefined || axes.length > 4)) return undefined;
+ if (value.buttons !== undefined && buttons === undefined) return undefined;
+ if (buttons !== undefined && new Set(buttons).size !== buttons.length) return undefined;
+ if (axes === undefined && buttons === undefined) return undefined;
+ return {
+ ...(axes === undefined ? {} : { axes }),
+ ...(buttons === undefined ? {} : { buttons }),
+ };
+}
+
+function gamepadAxes(value: unknown): number[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ const valid = value.every(
+ (axis) => typeof axis === "number" && Number.isFinite(axis) && axis >= -1 && axis <= 1,
+ );
+ return valid ? ([...value] as number[]) : undefined;
+}
+
+function gamepadButtons(value: unknown): number[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ const valid = value.every(
+ (button) =>
+ typeof button === "number" && Number.isInteger(button) && button >= 0 && button <= 15,
+ );
+ return valid ? ([...value] as number[]) : undefined;
+}
+
function validateLifecycleStep(
value: unknown,
scenarioPath: string,
diff --git a/packages/runtime-native/cmake/NativeEngine.cmake b/packages/runtime-native/cmake/NativeEngine.cmake
index cb87aeb77b..e3498e817c 100644
--- a/packages/runtime-native/cmake/NativeEngine.cmake
+++ b/packages/runtime-native/cmake/NativeEngine.cmake
@@ -692,8 +692,8 @@ if(NOT MYSTRAL_PLATFORM STREQUAL "ios" AND NOT MYSTRAL_PLATFORM STREQUAL "androi
# the executable names its game. It reaches no JS engine: inspect-js-free.mjs proves it. The loop
# links SDL, never a VM. Neither player is part of the default build (EXCLUDE_FROM_ALL).
add_library(tn_engine_player STATIC src/engine/player/mailbox.cpp src/engine/player/demo.cpp
- src/engine/player/run.cpp src/engine/player/skinned_crowd.cpp src/engine/player/world_walk.cpp
- src/platform/ui_overlay.cpp src/platform/input.cpp)
+ src/engine/player/gamepads.cpp src/engine/player/run.cpp src/engine/player/skinned_crowd.cpp
+ src/engine/player/world_walk.cpp src/platform/ui_overlay.cpp src/platform/input.cpp)
tn_native_engine_target(tn_engine_player)
# The UI seam is shared with the legacy host; the CSS backend is never linked here. The web view is
# opt-in (a game with ui.renderer "web" draws its HUD through it), so the default graph stays JS- and
@@ -753,6 +753,14 @@ if(NOT MYSTRAL_PLATFORM STREQUAL "ios" AND NOT MYSTRAL_PLATFORM STREQUAL "androi
tn_native_engine_test(tn-native-engine-skinned-crowd-test tests/native-engine/animation/skinned_crowd_test.cpp
native_engine_skinned_crowd_cpu=crowd_cpu)
target_link_libraries(tn-native-engine-skinned-crowd-test PRIVATE tn_engine_player)
+
+ # PRD-880 phase 2: the engine player's `tnPollGamepads` and the legacy host's
+ # `platform::getGamepadState` read one SDL3 -> W3C standard table, so they cannot report different
+ # counts or a different index for the same pad. Links tn_engine_player, which compiles both.
+ tn_native_engine_test(tn-native-engine-gamepad-standard-test
+ tests/native-engine/player/gamepad_standard_test.cpp
+ gamepad_standard=gamepad_standard)
+ target_link_libraries(tn-native-engine-gamepad-standard-test PRIVATE tn_engine_player)
endif()
# PRD-528 boxes 53/54: native Rapier stepped by the engine's FixedStepClock, with every body's
diff --git a/packages/runtime-native/cmake/NativeEngineAdapters.cmake b/packages/runtime-native/cmake/NativeEngineAdapters.cmake
index 816286ce25..7819b8a5d0 100644
--- a/packages/runtime-native/cmake/NativeEngineAdapters.cmake
+++ b/packages/runtime-native/cmake/NativeEngineAdapters.cmake
@@ -115,11 +115,10 @@ if(TN_PS4)
# reaches src/platform/ps4_main.cpp, which supplies the bundle path, the scanout size and V8's
# flags a console cannot pass on a command line.
add_library(tn-native-engine-player-v8 STATIC EXCLUDE_FROM_ALL
- src/engine/player/v8_main.cpp src/engine/player/gamepads.cpp src/platform/ps4_main.cpp)
+ src/engine/player/v8_main.cpp src/platform/ps4_main.cpp)
set_source_files_properties(src/engine/player/v8_main.cpp PROPERTIES COMPILE_DEFINITIONS "main=tn_player_v8_main")
else()
- add_executable(tn-native-engine-player-v8 EXCLUDE_FROM_ALL src/engine/player/v8_main.cpp
- src/engine/player/gamepads.cpp)
+ add_executable(tn-native-engine-player-v8 EXCLUDE_FROM_ALL src/engine/player/v8_main.cpp)
endif()
target_link_libraries(tn-native-engine-player-v8 PRIVATE tn_adapter_v8 tn_engine_player tn_engine_assets)
if(TARGET tn_engine_gltf)
diff --git a/packages/runtime-native/include/mystral/platform/input.h b/packages/runtime-native/include/mystral/platform/input.h
index dfa468a384..5d66a20073 100644
--- a/packages/runtime-native/include/mystral/platform/input.h
+++ b/packages/runtime-native/include/mystral/platform/input.h
@@ -12,6 +12,7 @@
#include
struct SDL_TouchFingerEvent;
+struct SDL_Gamepad;
namespace mystral {
namespace platform {
@@ -164,6 +165,18 @@ void processTouchEvent(const SDL_TouchFingerEvent& event);
*/
bool getGamepadState(int index, GamepadState* state);
+/**
+ * Fills `state` with one SDL gamepad in the W3C "standard" mapping — the single interface a game
+ * reads a controller through, so both hosts (`navigator.getGamepads` in the legacy runtime and
+ * `tnPollGamepads` in the owned engine player) report the same 4 axes and 16 buttons, the analog
+ * triggers as buttons 6 and 7. `SDL_OpenGamepad` has already applied SDL's controller database
+ * (Xbox, XInput clones, 8BitDo, Steam Deck, DualShock, DualSense); this is the last mile from its
+ * standard layout to the W3C indices. `index` and `id` are the caller's: they differ per host.
+ *
+ * No dead zone: `packages/core/src/input.ts` owns that, once, for every backend.
+ */
+bool fillStandardPad(SDL_Gamepad* gamepad, GamepadState& state);
+
/**
* Get number of connected gamepads
*/
diff --git a/packages/runtime-native/src/engine/player/gamepads.cpp b/packages/runtime-native/src/engine/player/gamepads.cpp
index 5c14d0b6ac..dec6a53917 100644
--- a/packages/runtime-native/src/engine/player/gamepads.cpp
+++ b/packages/runtime-native/src/engine/player/gamepads.cpp
@@ -10,17 +10,22 @@
#include
#include
#else
+#include "mystral/platform/input.h"
+
#include
#endif
// The platform half of `navigator.getGamepads()` (v8_main.cpp installs the JavaScript side). A game
// reads controllers through the standard Web Gamepad API and nothing else (packages/core/src/input.ts
// samples `axes` and `buttons[i].pressed` once per step), so both branches below produce the same
-// W3C "standard" mapping — the one a PS4 DualShock 4 and an SDL3 pad both already match.
+// W3C "standard" mapping — the one a PS4 DualShock 4 and an SDL3 pad both already match. The SDL3
+// branch reads the one table in `platform/input.cpp`, which the legacy host polls too; keeping a
+// second copy here is how `gamepadAxes: [4]` came to read a trigger on Android and nothing on desktop.
namespace tn::engine::player {
namespace {
+#if defined(TN_PS4)
constexpr int kButtonCount = 16; // the standard's 0..15; its 16 is the guide button, which neither API reports
constexpr int kAxisCount = 4; // left x/y, right x/y: the standard's triggers are buttons 6 and 7
constexpr int kLeftTrigger = 6;
@@ -29,8 +34,6 @@ constexpr int kRightTrigger = 7;
/** An axis as the standard reports it: -1..1, negative up and positive down, clamped (travel can overshoot). */
double standardAxis(double raw) { return raw < -1.0 ? -1.0 : raw > 1.0 ? 1.0 : raw; }
-#if defined(TN_PS4)
-
constexpr const char* kPadId = "DUALSHOCK 4 (PS4)";
// The W3C "standard" button index -> the DS4 bit for it, in the order the standard numbers them. The
@@ -143,31 +146,6 @@ int pollPads(mystral::platform::GamepadState* out, int max) {
#else
-// The W3C "standard" button index -> the SDL3 button for it, in the standard's own order.
-constexpr SDL_GamepadButton kButtons[kButtonCount] = {
- SDL_GAMEPAD_BUTTON_SOUTH, // 0 A / Cross
- SDL_GAMEPAD_BUTTON_EAST, // 1 B / Circle
- SDL_GAMEPAD_BUTTON_WEST, // 2 X / Square
- SDL_GAMEPAD_BUTTON_NORTH, // 3 Y / Triangle
- SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, // 4
- SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, // 5
- SDL_GAMEPAD_BUTTON_INVALID, // 6 the left trigger: an axis, read as a button below
- SDL_GAMEPAD_BUTTON_INVALID, // 7 the right trigger: the same
- SDL_GAMEPAD_BUTTON_BACK, // 8 select / share
- SDL_GAMEPAD_BUTTON_START, // 9 start / options
- SDL_GAMEPAD_BUTTON_LEFT_STICK, // 10
- SDL_GAMEPAD_BUTTON_RIGHT_STICK, // 11
- SDL_GAMEPAD_BUTTON_DPAD_UP, // 12
- SDL_GAMEPAD_BUTTON_DPAD_DOWN, // 13
- SDL_GAMEPAD_BUTTON_DPAD_LEFT, // 14
- SDL_GAMEPAD_BUTTON_DPAD_RIGHT, // 15
-};
-
-/** SDL's int16 travel as the standard's -1..1; -32768 clamps to -1. No dead zone. */
-double padAxis(SDL_Gamepad* pad, SDL_GamepadAxis which) {
- return standardAxis(SDL_GetGamepadAxis(pad, which) / 32767.0);
-}
-
int pollPads(mystral::platform::GamepadState* out, int max) {
if (out == nullptr || max < 1) return 0;
// The player's window already initialises SDL (platform/window.cpp); a host that did not still gets pads.
@@ -182,31 +160,13 @@ int pollPads(mystral::platform::GamepadState* out, int max) {
SDL_Gamepad* pad = SDL_GetGamepadFromID(ids[index]);
if (pad == nullptr) pad = SDL_OpenGamepad(ids[index]);
if (pad == nullptr) continue;
- mystral::platform::GamepadState& state = out[count++];
+ mystral::platform::GamepadState& state = out[count];
const char* name = SDL_GetGamepadName(pad);
state.index = index;
state.id = name != nullptr ? name : "Gamepad";
- state.connected = true;
- state.numAxes = kAxisCount;
- state.axes[0] = padAxis(pad, SDL_GAMEPAD_AXIS_LEFTX);
- state.axes[1] = padAxis(pad, SDL_GAMEPAD_AXIS_LEFTY);
- state.axes[2] = padAxis(pad, SDL_GAMEPAD_AXIS_RIGHTX);
- state.axes[3] = padAxis(pad, SDL_GAMEPAD_AXIS_RIGHTY);
- state.numButtons = kButtonCount;
- for (int button = 0; button < kButtonCount; ++button) {
- double value = 0;
- bool pressed = false;
- if (button == kLeftTrigger || button == kRightTrigger) {
- value = padAxis(pad, button == kLeftTrigger ? SDL_GAMEPAD_AXIS_LEFT_TRIGGER
- : SDL_GAMEPAD_AXIS_RIGHT_TRIGGER);
- pressed = value > 0.5; // triggers travel 0..1, so the standard's reading is a threshold
- } else {
- pressed = SDL_GetGamepadButton(pad, kButtons[button]);
- value = pressed ? 1.0 : 0.0;
- }
- state.buttons[button] = pressed;
- state.buttonValues[button] = value;
- }
+ // One table for both hosts (platform/input.cpp): SDL's standard layout -> W3C indices.
+ if (!mystral::platform::fillStandardPad(pad, state)) continue;
+ ++count;
}
SDL_free(ids);
return count;
diff --git a/packages/runtime-native/src/platform/input.cpp b/packages/runtime-native/src/platform/input.cpp
index 98c0920b89..e034177f38 100644
--- a/packages/runtime-native/src/platform/input.cpp
+++ b/packages/runtime-native/src/platform/input.cpp
@@ -682,9 +682,8 @@ void processGamepadConnected(SDL_JoystickID id) {
data.gamepad.index = index;
data.gamepad.id = SDL_GetGamepadName(gamepad);
- data.gamepad.connected = true;
- data.gamepad.numAxes = 6; // Standard gamepad has 6 axes
- data.gamepad.numButtons = 17; // Standard gamepad has 17 buttons
+ // The same table `getGamepadState` reads, so the event and the poll cannot disagree.
+ fillStandardPad(gamepad, data.gamepad);
g_gamepadCallback(data);
}
@@ -761,6 +760,62 @@ void processResize(int width, int height) {
g_resizeCallback(data);
}
+/**
+ * The W3C "standard" button index -> the SDL3 button for it, in the standard's own order. Index 6
+ * and 7 are the analog triggers, which SDL reports as axes and this reads past halfway.
+ */
+static const SDL_GamepadButton kStandardButtons[16] = {
+ SDL_GAMEPAD_BUTTON_SOUTH, // 0 A / Cross
+ SDL_GAMEPAD_BUTTON_EAST, // 1 B / Circle
+ SDL_GAMEPAD_BUTTON_WEST, // 2 X / Square
+ SDL_GAMEPAD_BUTTON_NORTH, // 3 Y / Triangle
+ SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, // 4
+ SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, // 5
+ SDL_GAMEPAD_BUTTON_INVALID, // 6 the left trigger: an axis, read below
+ SDL_GAMEPAD_BUTTON_INVALID, // 7 the right trigger: the same
+ SDL_GAMEPAD_BUTTON_BACK, // 8 select / share
+ SDL_GAMEPAD_BUTTON_START, // 9 start / options
+ SDL_GAMEPAD_BUTTON_LEFT_STICK, // 10
+ SDL_GAMEPAD_BUTTON_RIGHT_STICK, // 11
+ SDL_GAMEPAD_BUTTON_DPAD_UP, // 12
+ SDL_GAMEPAD_BUTTON_DPAD_DOWN, // 13
+ SDL_GAMEPAD_BUTTON_DPAD_LEFT, // 14
+ SDL_GAMEPAD_BUTTON_DPAD_RIGHT, // 15
+};
+
+/** SDL's int16 travel as the standard's -1..1; -32768 clamps to -1. No dead zone. */
+static double standardPadAxis(SDL_Gamepad* gamepad, SDL_GamepadAxis which) {
+ const double raw = SDL_GetGamepadAxis(gamepad, which) / 32767.0;
+ return raw < -1.0 ? -1.0 : raw > 1.0 ? 1.0 : raw;
+}
+
+bool fillStandardPad(SDL_Gamepad* gamepad, GamepadState& state) {
+ if (gamepad == nullptr) return false;
+ state.connected = true;
+ state.numAxes = 4; // left x/y, right x/y: the standard's triggers are buttons 6 and 7
+ state.numButtons = 16; // the standard's 0..15; its 16 is guide, which SDL reports as its own button
+ state.axes[0] = standardPadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX);
+ state.axes[1] = standardPadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY);
+ state.axes[2] = standardPadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX);
+ state.axes[3] = standardPadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY);
+ for (int button = 0; button < state.numButtons; button++) {
+ double value = 0;
+ bool pressed = false;
+ if (button == 6 || button == 7) {
+ // Triggers travel 0..1, so the standard's reading is a threshold and its value is the travel.
+ value = standardPadAxis(gamepad, button == 6 ? SDL_GAMEPAD_AXIS_LEFT_TRIGGER
+ : SDL_GAMEPAD_AXIS_RIGHT_TRIGGER);
+ pressed = value > 0.5;
+ } else {
+ pressed = SDL_GetGamepadButton(gamepad, kStandardButtons[button]);
+ value = pressed ? 1.0 : 0.0;
+ }
+ state.buttons[button] = pressed;
+ state.buttonValues[button] = value;
+ }
+ return true;
+}
+
/**
* Get gamepad state
*/
@@ -779,61 +834,7 @@ bool getGamepadState(int index, GamepadState* state) {
state->index = index;
state->id = SDL_GetGamepadName(gamepad);
- state->connected = true;
- state->numAxes = 6;
- state->numButtons = 17;
-
- // Standard gamepad mapping (matches W3C Gamepad API)
- // Axes: leftStickX, leftStickY, rightStickX, rightStickY, leftTrigger, rightTrigger
- state->axes[0] = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTX) / 32767.0;
- state->axes[1] = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFTY) / 32767.0;
- state->axes[2] = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTX) / 32767.0;
- state->axes[3] = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHTY) / 32767.0;
- state->axes[4] = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_LEFT_TRIGGER) / 32767.0;
- state->axes[5] = SDL_GetGamepadAxis(gamepad, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER) / 32767.0;
-
- // Buttons (W3C standard mapping)
- // 0=A, 1=B, 2=X, 3=Y, 4=LB, 5=RB, 6=LT, 7=RT, 8=Back, 9=Start,
- // 10=LS, 11=RS, 12=DUp, 13=DDown, 14=DLeft, 15=DRight, 16=Home
- SDL_GamepadButton buttonMap[] = {
- SDL_GAMEPAD_BUTTON_SOUTH, // 0 - A/Cross
- SDL_GAMEPAD_BUTTON_EAST, // 1 - B/Circle
- SDL_GAMEPAD_BUTTON_WEST, // 2 - X/Square
- SDL_GAMEPAD_BUTTON_NORTH, // 3 - Y/Triangle
- SDL_GAMEPAD_BUTTON_LEFT_SHOULDER, // 4 - LB
- SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER, // 5 - RB
- SDL_GAMEPAD_BUTTON_INVALID, // 6 - LT (handled via axis)
- SDL_GAMEPAD_BUTTON_INVALID, // 7 - RT (handled via axis)
- SDL_GAMEPAD_BUTTON_BACK, // 8 - Back/Select
- SDL_GAMEPAD_BUTTON_START, // 9 - Start
- SDL_GAMEPAD_BUTTON_LEFT_STICK, // 10 - LS Click
- SDL_GAMEPAD_BUTTON_RIGHT_STICK, // 11 - RS Click
- SDL_GAMEPAD_BUTTON_DPAD_UP, // 12
- SDL_GAMEPAD_BUTTON_DPAD_DOWN, // 13
- SDL_GAMEPAD_BUTTON_DPAD_LEFT, // 14
- SDL_GAMEPAD_BUTTON_DPAD_RIGHT, // 15
- SDL_GAMEPAD_BUTTON_GUIDE, // 16 - Home/Guide
- };
-
- for (int i = 0; i < 17; i++) {
- if (i == 6) {
- // Left trigger as button
- state->buttons[i] = state->axes[4] > 0.5;
- state->buttonValues[i] = state->axes[4];
- } else if (i == 7) {
- // Right trigger as button
- state->buttons[i] = state->axes[5] > 0.5;
- state->buttonValues[i] = state->axes[5];
- } else if (buttonMap[i] != SDL_GAMEPAD_BUTTON_INVALID) {
- state->buttons[i] = SDL_GetGamepadButton(gamepad, buttonMap[i]);
- state->buttonValues[i] = state->buttons[i] ? 1.0 : 0.0;
- } else {
- state->buttons[i] = false;
- state->buttonValues[i] = 0.0;
- }
- }
-
- return true;
+ return fillStandardPad(gamepad, *state);
}
/**
diff --git a/packages/runtime-native/tests/native-engine/player/gamepad_standard_test.cpp b/packages/runtime-native/tests/native-engine/player/gamepad_standard_test.cpp
new file mode 100644
index 0000000000..1f4326ed54
--- /dev/null
+++ b/packages/runtime-native/tests/native-engine/player/gamepad_standard_test.cpp
@@ -0,0 +1,152 @@
+// One native table for every host (PRD-880 phase 2).
+//
+// The SDL3 -> W3C "standard" table used to exist twice: once in the owned engine player
+// (`tnPollGamepads`) and once in the legacy host (`platform::getGamepadState`), with different
+// counts — 6 axes and 17 buttons on the legacy side, 4 and 16 on the engine side — so a binding
+// such as `gamepadAxes: [4]` read a trigger on Android and nothing on desktop. This test attaches
+// one SDL virtual gamepad and requires both entry points to report the same state: the left stick
+// on axes 0 and 1, the right trigger on button 7, the south button on button 0.
+
+#include "engine/player/gamepads.h"
+#include "mystral/platform/input.h"
+
+#include
+
+#include
+#include
+#include
+#include
+
+namespace platform = mystral::platform;
+
+// window.cpp declares the same hook: it is the SDL_EVENT_GAMEPAD_ADDED handler, not public API.
+namespace mystral {
+namespace platform {
+void processGamepadConnected(SDL_JoystickID id);
+}
+} // namespace mystral
+
+namespace {
+
+int failures = 0;
+
+void require(bool condition, const char* what) {
+ if (condition) return;
+ ++failures;
+ std::fprintf(stderr, "FAILED: %s\n", what);
+}
+
+void requireClose(double actual, double expected, const char* what) {
+ if (std::fabs(actual - expected) <= 0.0001) return;
+ ++failures;
+ std::fprintf(stderr, "FAILED: %s (expected %.4f, got %.4f)\n", what, expected, actual);
+}
+
+/** A virtual pad SDL treats as a gamepad, with a mapping so `SDL_OpenGamepad` applies. */
+struct AttachedPad {
+ SDL_JoystickID id = 0;
+ SDL_Joystick* joystick = nullptr;
+};
+
+AttachedPad attachPad(const char* name) {
+ SDL_VirtualJoystickDesc desc;
+ SDL_INIT_INTERFACE(&desc);
+ desc.type = SDL_JOYSTICK_TYPE_GAMEPAD;
+ desc.naxes = 6;
+ desc.nbuttons = 16;
+ desc.nhats = 1;
+ desc.name = name;
+ AttachedPad pad;
+ pad.id = SDL_AttachVirtualJoystick(&desc);
+ if (pad.id == 0) {
+ std::fprintf(stderr, "FAILED: SDL_AttachVirtualJoystick: %s\n", SDL_GetError());
+ return pad;
+ }
+ // A virtual joystick has no database entry, so the mapping is added for its own GUID — before the
+ // pad is opened as a gamepad, which is when SDL looks the mapping up.
+ char guid[64];
+ SDL_GUIDToString(SDL_GetJoystickGUIDForID(pad.id), guid, sizeof(guid));
+ const std::string mapping = std::string(guid) +
+ ",TN Test Pad,a:b0,b:b1,x:b2,y:b3,back:b6,start:b7,leftstick:b8,rightstick:b9,"
+ "leftshoulder:b4,rightshoulder:b5,dpup:h0.1,dpdown:h0.4,dpleft:h0.8,dpright:h0.2,"
+ "leftx:a0,lefty:a1,rightx:a2,righty:a3,lefttrigger:a4,righttrigger:a5,";
+ if (SDL_AddGamepadMapping(mapping.c_str()) < 0) {
+ std::fprintf(stderr, "FAILED: SDL_AddGamepadMapping: %s\n", SDL_GetError());
+ return pad;
+ }
+ SDL_Gamepad* gamepad = SDL_OpenGamepad(pad.id);
+ if (gamepad == nullptr) {
+ std::fprintf(stderr, "FAILED: SDL_OpenGamepad: %s\n", SDL_GetError());
+ return pad;
+ }
+ pad.joystick = SDL_GetGamepadJoystick(gamepad);
+ return pad;
+}
+
+bool samePad(const platform::GamepadState& engine, const platform::GamepadState& legacy) {
+ bool same = engine.numAxes == legacy.numAxes && engine.numButtons == legacy.numButtons &&
+ engine.connected == legacy.connected;
+ for (int index = 0; index < engine.numAxes && index < legacy.numAxes; ++index)
+ same = same && std::fabs(engine.axes[index] - legacy.axes[index]) <= 0.0001;
+ for (int index = 0; index < engine.numButtons && index < legacy.numButtons; ++index)
+ same = same && engine.buttons[index] == legacy.buttons[index] &&
+ std::fabs(engine.buttonValues[index] - legacy.buttonValues[index]) <= 0.0001;
+ return same;
+}
+
+int gamepadStandard() {
+ if (!SDL_Init(SDL_INIT_GAMEPAD)) {
+ std::fprintf(stderr, "FAILED: SDL_Init(SDL_INIT_GAMEPAD): %s\n", SDL_GetError());
+ return 1;
+ }
+ const AttachedPad pad = attachPad("TN Test Pad");
+ if (pad.id == 0 || pad.joystick == nullptr) return 1;
+ SDL_Joystick* joystick = pad.joystick;
+
+ // Full right on the left stick, half-travel right trigger, south button down.
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTX, 32767);
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_LEFTY, 0);
+ SDL_SetJoystickVirtualAxis(joystick, SDL_GAMEPAD_AXIS_RIGHT_TRIGGER, 20000);
+ SDL_SetJoystickVirtualButton(joystick, SDL_GAMEPAD_BUTTON_SOUTH, true);
+ SDL_UpdateJoysticks();
+
+ // The legacy host tracks pads through its SDL_EVENT_GAMEPAD_ADDED handler.
+ platform::processGamepadConnected(pad.id);
+ platform::GamepadState legacy{};
+ require(platform::getGamepadState(0, &legacy), "the legacy host reports the attached pad");
+
+ platform::GamepadState engineStates[4]{};
+ const int engineCount = tn::engine::player::tnPollGamepads(engineStates, 4);
+ const platform::GamepadState* engine = nullptr;
+ for (int index = 0; index < engineCount; ++index)
+ if (engineStates[index].id.find("TN Test Pad") != std::string::npos)
+ engine = &engineStates[index];
+ require(engine != nullptr, "the engine player reports the attached pad");
+ if (engine == nullptr) return 1;
+
+ require(legacy.numAxes == 4, "the legacy host reports 4 stick axes");
+ require(legacy.numButtons == 16, "the legacy host reports the standard's 16 buttons");
+ require(engine->numAxes == 4, "the engine player reports 4 stick axes");
+ require(engine->numButtons == 16, "the engine player reports the standard's 16 buttons");
+ require(samePad(*engine, legacy), "both entry points report the same pad");
+
+ requireClose(engine->axes[0], 1.0, "the left stick's x is on axis 0");
+ requireClose(engine->axes[1], 0.0, "the left stick's y is on axis 1");
+ require(engine->buttons[0], "the south button is button 0");
+ require(engine->buttons[7], "the right trigger is button 7");
+ require(!engine->buttons[6], "the left trigger stays up");
+
+ require(SDL_DetachVirtualJoystick(pad.id), "the virtual pad detaches");
+ SDL_Quit();
+ if (failures == 0) std::printf("gamepad_standard: ok\n");
+ return failures == 0 ? 0 : 1;
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ const char* test = argc > 1 ? argv[1] : "gamepad_standard";
+ if (std::strcmp(test, "gamepad_standard") == 0) return gamepadStandard();
+ std::fprintf(stderr, "unknown test case: %s\n", test);
+ return 1;
+}