diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index a01951a..527a386 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -214,8 +214,12 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme
- **initXR()** - Detects WebXR support (`navigator.xr.isSessionSupported('immersive-vr')`)
- **toggleXRSession(renderer)** - Creates/ends XR session and updates renderer
- **updateXRControllerInput()** - Reads gamepad data from input sources each frame
-- **getXRControllerInput()** - Returns { leftThumbstick, rightThumbstick, rightTrigger }
-- **xrState** - Global state tracking: enabled flag, head pose, controller map
+- **getXRControllerInput()** - Returns thumbsticks, thumbstick-button state, trigger,
+ grip, A, and B inputs
+- **getXRActionInput()** - Returns the capability-gated movement, turn, fire, and
+ jump actions used by gameplay
+- **xrState** - Global state tracking: enabled flag, head pose, controller map,
+ visibility, and physical-input readiness
### Integration Points
@@ -228,9 +232,10 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme
**input.js:**
- `updateVirtualInputFromXR()` - Maps controller input to virtualInput:
- - Left thumbstick Y → forward/backward
- - Right thumbstick X → turn left/right
- - Right trigger → fire (Phase 2)
+ - Right thumbstick Y → forward/backward, with left-stick fallback
+ - Right thumbstick X → turn left/right, with left-stick fallback
+ - Right trigger or A button → fire
+ - B button or grip → jump
**client.js:**
- XR button added to settings HUD (enabled/disabled based on support)
@@ -238,6 +243,8 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme
- `updateXRControllerInput()` called each frame before handleInputEvents
- `toggleXRSession()` triggered by XR button click
- First-person camera mode automatically enabled when entering VR
+- XR gameplay ignores keyboard, mouse, touch, and desktop-gamepad input until
+ a physical device covers all required actions
**index.html:**
- XR button added to settings: `id="xrBtn" title="Enter WebXR VR Mode"`
@@ -246,25 +253,27 @@ WebXR support enables VR gameplay on headsets like Meta Quest 2. Phase 1 impleme
| Input | Binding | Effect |
|-------|---------|--------|
-| Left Thumbstick Up/Down | Axes 1 | Forward/Backward movement |
-| Right Thumbstick Left/Right | Axes 2 | Tank rotation |
-| Right Trigger | Button 0 | Fire (Phase 2) |
+| Right Thumbstick Up/Down | Axes 3 (fallback: left axes 1) | Forward/Backward movement |
+| Right Thumbstick Left/Right | Axes 2 (fallback: left axes 0) | Tank rotation |
+| Right Trigger or A | Buttons 0 or 4 | Fire |
+| B or grip | Buttons 5 or 1 | Jump |
+| Either thumbstick press | Button 3 | Exit VR and open Settings |
## How It Works (Phase 1)
-1. User clicks VR Mode button on Quest 2
-2. Browser requests immersive-vr session
+1. User clicks VR Mode button on a supported headset
+2. Browser requests the native XR session
3. Renderer switches to stereo rendering
4. Each frame:
- Controller thumbstick positions read from gamepad input sources
- - Converted to virtualInput (forward, turn)
+ - Converted to capability-gated virtualInput (forward, turn, fire, jump)
- Used by handleInputEvents for movement
- Tank rotation independent of head direction
- Three.js automatically positions camera for stereo view + head tracking
## Future Work (Phase 2+)
-- **Phase 2:** Trigger button for firing (direction = tank facing, not head)
+- **Phase 2:** Cross-device controller mapping and comfort settings
- **Phase 3:** Hand tracking, comfort settings, snap turning option
- **Phase 4:** VR-optimized UI, voice commands, controller haptics feedback
diff --git a/README.md b/README.md
index af7a927..ded9c51 100644
--- a/README.md
+++ b/README.md
@@ -235,6 +235,12 @@ For remote access, terminate TLS at the reverse proxy and open the game over
`https://`; the client automatically uses `wss://` for its WebSocket connection
when the page is served over HTTPS.
+XR gameplay uses physical input. Upstream Quest bindings use the right
+thumbstick for movement and turning, with the left stick as a fallback; the
+right trigger or A fires, B or grip jumps, and pressing either thumbstick exits
+VR and opens Settings. One controller is accepted when it covers movement,
+turning, firing, and jumping; a two-controller setup remains preferred.
+
If the deployment sets a restrictive `Permissions-Policy` header, allow
`xr-spatial-tracking=(self)`. The Node.js server does not terminate TLS itself,
so HTTPS and the corresponding WebSocket proxy configuration are deployment
diff --git a/docs/webxr-validation.md b/docs/webxr-validation.md
index 754e13d..bede5dc 100644
--- a/docs/webxr-validation.md
+++ b/docs/webxr-validation.md
@@ -29,13 +29,21 @@ or deployment configuration.
## Controller mapping
-- Move the left thumbstick forward and backward; the tank should move along its
- current heading.
+- Connect a physical controller before expecting XR gameplay input. A complete
+ single controller is accepted; otherwise the preferred pair is left/right.
+- Move the right thumbstick forward and backward; the tank should move along
+ its current heading. The left stick is the fallback when the right axis is
+ unavailable.
- Move the right thumbstick left and right; the tank should rotate without
- changing its heading from head movement.
+ changing its heading from head movement. The left stick is the fallback when
+ the right axis is unavailable.
- Press the right trigger or A button to fire.
- Press the B button or grip button to jump.
+- Press either controller thumbstick button to exit VR and show the Settings
+ HUD.
- Release every control and confirm that no stale input continues to act.
+- Remove a required input device and confirm gameplay remains blocked until
+ complete action coverage is restored.
## Session lifecycle
@@ -45,6 +53,8 @@ or deployment configuration.
normal desktop loop resumes without a page reload.
- Hide and restore the headset view, then confirm that controllers and movement
continue to work after visibility returns.
+- While the headset view is hidden, confirm movement, firing, and jumping are
+ neutralized rather than latched.
- Enter and exit VR Mode a second time and confirm that no duplicate input or
animation callbacks are active.
diff --git a/package.json b/package.json
index ec96173..5288d7f 100644
--- a/package.json
+++ b/package.json
@@ -9,9 +9,10 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"check:server": "node --check server.js",
- "check": "npm run check:server && npm run lint && npm run check:controls-docs && npm run test:shot-limits",
+ "check": "npm run check:server && npm run lint && npm run check:controls-docs && npm run test:shot-limits && npm run test:webxr-capabilities",
"check:controls-docs": "node scripts/check-controls-docs.mjs",
"test:shot-limits": "node scripts/test-shot-limits.mjs",
+ "test:webxr-capabilities": "node scripts/test-webxr-capabilities.mjs",
"release:prepare": "node scripts/prepare-release.mjs",
"release:check": "node scripts/check-release.mjs",
"release:check:increment": "node scripts/check-tag-increment.mjs",
diff --git a/public/client.js b/public/client.js
index 6e8acb1..7fe724d 100644
--- a/public/client.js
+++ b/public/client.js
@@ -51,6 +51,8 @@ import {
getXRControllerInput,
setNormalAnimationLoop,
isXREnabled,
+ isXRInputReady,
+ getXRInputStatus,
} from './webxr.js';
import { createVoiceManager } from './voice.js';
import { normalizeShotSlotCount } from './shot-limits.mjs';
@@ -1901,6 +1903,19 @@ window.addEventListener('DOMContentLoaded', () => {
}
// Initialize WebXR support
+ window.addEventListener('webxrinputchange', event => {
+ if (!isXREnabled() || !event.detail?.message) return;
+ showMessage(`WebXR input: ${event.detail.message}`);
+ });
+ window.addEventListener('webxrsessionchange', event => {
+ Object.keys(keys).forEach(code => {
+ keys[code] = false;
+ });
+ if (event.detail?.enabled) return;
+ xrSettingsShortcutLatched = false;
+ setXRButtonState(false);
+ });
+
initXR().then(mode => {
showMessage(`WebXR: ${mode}`, 'info');
const xrBtn = document.getElementById('xrBtn');
@@ -1924,7 +1939,10 @@ window.addEventListener('DOMContentLoaded', () => {
setXRButtonState(true);
// Force first-person camera when entering VR
cameraMode = 'first-person';
- showMessage('✓ WebXR VR Mode: ON');
+ const inputStatus = getXRInputStatus();
+ showMessage(inputStatus.ready
+ ? `✓ WebXR VR Mode: ON (${inputStatus.mode} controller input ready)`
+ : `WebXR VR Mode: ON — ${inputStatus.message}`);
} else {
setXRButtonState(false);
if (!wasEnabled) {
@@ -4472,35 +4490,50 @@ function handleInputEvents() {
intendedForward = myTank.userData.jumpForwardSpeed || 0;
intendedRotation = myTank.userData.rotationSpeed || 0;
} else {
- // Use virtual input if gamepad connected, XR enabled, or virtual controls enabled
- if (isGamepadConnected() || virtualControlsEnabled || isXREnabled()) {
- intendedForward = virtualInput.forward;
- intendedRotation = virtualInput.turn;
- if (jumpDirection === null && virtualInput.jump) {
+ const xrActive = isXREnabled();
+ const xrInputReady = isXRInputReady();
+
+ if (xrActive) {
+ // Immersive XR is controller-only. Desktop keyboard, mouse, touch, and
+ // gamepad paths must not bypass the physical-input capability gate.
+ if (xrInputReady) {
+ intendedForward = virtualInput.forward;
+ intendedRotation = virtualInput.turn;
+ if (jumpDirection === null && virtualInput.jump) {
+ intendedY = 1;
+ jumpTriggered = true;
+ }
+ }
+ } else {
+ if (isGamepadConnected() || virtualControlsEnabled) {
+ intendedForward = virtualInput.forward;
+ intendedRotation = virtualInput.turn;
+ if (jumpDirection === null && virtualInput.jump) {
+ intendedY = 1;
+ jumpTriggered = true;
+ }
+ }
+ const wasdKeys = ['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'];
+ let wasdPressed = false;
+ for (const code of wasdKeys) {
+ if (keys[code]) {
+ intendedForward += (code === 'KeyW' || code === 'ArrowUp') ? 1 : (code === 'KeyS' || code === 'ArrowDown') ? -1 : 0;
+ intendedRotation += (code === 'KeyA' || code === 'ArrowLeft') ? 1 : (code === 'KeyD' || code === 'ArrowRight') ? -1 : 0;
+ wasdPressed = true;
+ }
+ }
+ if (wasdPressed && mouseControlEnabled) {
+ toggleMouseMode();
+ }
+ if ((keys['Tab']) && jumpDirection === null) {
intendedY = 1;
jumpTriggered = true;
}
- }
- const wasdKeys = ['ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'KeyW', 'KeyA', 'KeyS', 'KeyD'];
- let wasdPressed = false;
- for (const code of wasdKeys) {
- if (keys[code]) {
- intendedForward += (code === 'KeyW' || code === 'ArrowUp') ? 1 : (code === 'KeyS' || code === 'ArrowDown') ? -1 : 0;
- intendedRotation += (code === 'KeyA' || code === 'ArrowLeft') ? 1 : (code === 'KeyD' || code === 'ArrowRight') ? -1 : 0;
- wasdPressed = true;
+ if (mouseControlEnabled) {
+ if (typeof mouseY !== 'undefined') intendedForward = -mouseY;
+ if (typeof mouseX !== 'undefined') intendedRotation = -mouseX;
}
}
- if (wasdPressed && mouseControlEnabled) {
- toggleMouseMode();
- }
- if ((keys['Tab']) && jumpDirection === null) {
- intendedY = 1;
- jumpTriggered = true;
- }
- if (mouseControlEnabled) {
- if (typeof mouseY !== 'undefined') intendedForward = -mouseY;
- if (typeof mouseX !== 'undefined') intendedRotation = -mouseX;
- }
}
const reverseSpeedRatio = Number.isFinite(gameConfig?.REVERSE_SPEED_RATIO)
? gameConfig.REVERSE_SPEED_RATIO
@@ -4906,8 +4939,12 @@ function handleMotion(deltaTime) {
lastSentTime = now;
}
- // Fire button: keyboard Space, mobile/XR/gamepad virtualInput.fire
- const firePressed = (!isMobile && keys['Space']) || ((isMobile || isXREnabled() || isGamepadConnected()) && virtualInput.fire);
+ // Fire button: keyboard Space outside XR, or a valid physical XR/gamepad
+ // action. Keyboard and mouse paths cannot bypass the XR capability gate.
+ const xrActive = isXREnabled();
+ const firePressed = xrActive
+ ? (isXRInputReady() && virtualInput.fire)
+ : ((!isMobile && keys['Space']) || ((isMobile || isGamepadConnected()) && virtualInput.fire));
const fireNow = performance.now();
if (firePressed && fireNow >= nextAllowedShotAt) {
const maxActiveShots = normalizeShotSlotCount(gameConfig?.SHOT_MAX_ACTIVE);
diff --git a/public/index.html b/public/index.html
index 336072f..fcc3016 100644
--- a/public/index.html
+++ b/public/index.html
@@ -241,7 +241,8 @@
Nearby Voice
3D Viewing Modes
- Anaglyph 3D — Red/Cyan glasses for 3D effect
- - VR Mode — WebXR-compatible headsets
+ - VR Mode — Native WebXR-compatible headsets
+ - Thumbstick press — Exit VR and show Settings
Source Code
diff --git a/public/input.js b/public/input.js
index 48e049a..8647ff6 100644
--- a/public/input.js
+++ b/public/input.js
@@ -8,7 +8,7 @@
// Handles keyboard, mouse, and touch input for the game.
// Exports: setupInputHandlers, virtualInput, keys
-import { getXRControllerInput, xrState } from './webxr.js';
+import { getXRActionInput, xrState } from './webxr.js';
// Shared virtual input state exposed to the game loop.
export let virtualInput = { forward: 0, turn: 0, fire: false, jump: false };
@@ -415,41 +415,24 @@ export function updateVirtualInputFromXR() {
return;
}
- const controllerInput = getXRControllerInput();
- const leftThumbstick = controllerInput.leftThumbstick || { x: 0, y: 0 };
- const rightThumbstick = controllerInput.rightThumbstick || { x: 0, y: 0 };
-
- const deadzone = 0.15;
- const applyDeadzone = (value) => {
- if (!Number.isFinite(value) || Math.abs(value) < deadzone) return 0;
- const sign = value > 0 ? 1 : -1;
- return sign * ((Math.abs(value) - deadzone) / (1 - deadzone));
- };
-
- const leftX = applyDeadzone(leftThumbstick.x || 0);
- const leftY = applyDeadzone(leftThumbstick.y || 0);
- const rightX = applyDeadzone(rightThumbstick.x || 0);
- const rightY = applyDeadzone(rightThumbstick.y || 0);
-
- // Right-stick-primary locomotion for Quest ergonomics.
- const forwardAxis = Math.abs(rightY) > 0 ? rightY : leftY;
- const newForward = -forwardAxis;
- xrInputState.forward = newForward;
-
- // Prefer right-stick X for turning, with left-stick X fallback.
- xrInputState.turn = -(Math.abs(rightX) > 0 ? rightX : leftX);
-
- // Right trigger OR A button: fire
- xrInputState.fire = controllerInput.rightTrigger > 0.5 || controllerInput.buttonA;
+ const actionInput = getXRActionInput();
+ if (!actionInput.ready) {
+ // Do not allow a disconnected or insufficient device to leave stale
+ // gameplay input active while the headset session is still running.
+ resetXRInput();
+ return;
+ }
- // B button OR side grip button: jump
- xrInputState.jump = controllerInput.buttonB || controllerInput.buttonGrip;
+ xrInputState.forward = actionInput.forward;
+ xrInputState.turn = actionInput.turn;
+ xrInputState.fire = actionInput.fire;
+ xrInputState.jump = actionInput.jump;
syncVirtualInput();
// Debug logging every 60 frames
vxrFrameCounter++;
if (vxrFrameCounter % 60 === 0) {
- //debugLog(`virtualInput: forward=${newForward.toFixed(2)}, turn=${xrInputState.turn.toFixed(2)}, fire=${xrInputState.fire}, jump=${xrInputState.jump}`);
+ //debugLog(`virtualInput: forward=${xrInputState.forward.toFixed(2)}, turn=${xrInputState.turn.toFixed(2)}, fire=${xrInputState.fire}, jump=${xrInputState.jump}`);
}
}
diff --git a/public/webxr-capabilities.mjs b/public/webxr-capabilities.mjs
new file mode 100644
index 0000000..f1fd364
--- /dev/null
+++ b/public/webxr-capabilities.mjs
@@ -0,0 +1,308 @@
+/*
+ * Copyright (C) 2025-2026 Tim Riker
+ * Licensed under the GNU Affero General Public License v3.0.
+ * Source: https://github.com/timriker/bzo
+ * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html
+ */
+
+// Keep WebXR input decisions separate from the session and renderer lifecycle.
+// The browser-facing webxr.js module uses these helpers to expose the current
+// physical-input status without changing the upstream controller bindings.
+
+export const XR_REQUIRED_ACTIONS = Object.freeze([
+ 'move',
+ 'turn',
+ 'fire',
+ 'jump',
+]);
+
+const DEFAULT_DEADZONE = 0.15;
+
+function toArray(value) {
+ return value ? Array.from(value) : [];
+}
+
+function finiteNumber(value, fallback = 0) {
+ return Number.isFinite(value) ? value : fallback;
+}
+
+export function applyDeadzone(value, deadzone = DEFAULT_DEADZONE) {
+ const normalized = finiteNumber(value);
+ const threshold = Math.max(0, Math.min(0.99, finiteNumber(deadzone, DEFAULT_DEADZONE)));
+ if (Math.abs(normalized) <= threshold) return 0;
+ const sign = normalized < 0 ? -1 : 1;
+ return sign * ((Math.abs(normalized) - threshold) / (1 - threshold));
+}
+
+function readButton(buttons, index) {
+ const button = buttons[index];
+ if (!button) {
+ return { available: false, pressed: false, value: 0 };
+ }
+
+ return {
+ available: true,
+ pressed: Boolean(button.pressed || finiteNumber(button.value) > 0.5),
+ value: Math.max(0, Math.min(1, finiteNumber(button.value, button.pressed ? 1 : 0))),
+ };
+}
+
+function anyAvailableButton(buttons, indices) {
+ const candidates = indices
+ .map(index => readButton(buttons, index))
+ .filter(button => button.available);
+
+ if (candidates.length === 0) {
+ return { available: false, pressed: false, value: 0 };
+ }
+
+ return {
+ available: true,
+ pressed: candidates.some(button => button.pressed),
+ value: Math.max(...candidates.map(button => button.value)),
+ };
+}
+
+function hasEventButton(eventButtons, name) {
+ return Boolean(eventButtons && typeof eventButtons[name] === 'boolean');
+}
+
+function readStick(axes, { sourceType, handedness }) {
+ // The owner’s current Quest mapping prefers the right-hand [2,3] pair for
+ // a right controller, while the left-hand controller uses [0,1]. Generic
+ // Gamepad devices use the standard [0,1] pair.
+ const preferredPair = sourceType === 'xr' && handedness === 'right'
+ ? [2, 3]
+ : [0, 1];
+ const pairs = [preferredPair, [0, 1], [2, 3]];
+ let firstAvailable = null;
+
+ for (const [xIndex, yIndex] of pairs) {
+ const xValue = axes[xIndex];
+ const yValue = axes[yIndex];
+ if (Number.isFinite(xValue) || Number.isFinite(yValue)) {
+ const candidate = {
+ available: true,
+ x: applyDeadzone(xValue),
+ y: applyDeadzone(yValue),
+ };
+ if (!firstAvailable) firstAvailable = candidate;
+ // Some runtimes expose an unused axis pair as finite zeroes while the
+ // actual stick is reported in the other pair. Prefer non-zero input and
+ // retain the preferred pair only when all available pairs are neutral.
+ if (candidate.x !== 0 || candidate.y !== 0) return candidate;
+ }
+ }
+
+ return firstAvailable || { available: false, x: 0, y: 0 };
+}
+
+/**
+ * Convert an XRInputSource.Gamepad or standard Gamepad into a stable,
+ * hardware-neutral action device.
+ */
+export function createSemanticDevice({
+ id = 'unknown',
+ sourceType = 'xr',
+ handedness = 'none',
+ profiles = [],
+ targetRayMode = null,
+ gamepad = null,
+ eventButtons = null,
+} = {}) {
+ const axes = toArray(gamepad?.axes);
+ const buttons = toArray(gamepad?.buttons);
+ const normalizedHandedness = handedness || 'none';
+ const xrDevice = sourceType === 'xr';
+ const stick = readStick(axes, {
+ sourceType,
+ handedness: normalizedHandedness,
+ });
+ const xrStandard = gamepad?.mapping === 'xr-standard';
+
+ // XR action buttons follow the owner’s current bindings. For a generic
+ // standard Gamepad, include the conventional trigger alternatives so one
+ // Bluetooth/Xbox-like device can cover the complete action set.
+ const fireIndices = xrDevice ? [0, 4] : [0, 7];
+ const jumpIndices = xrDevice ? [1, 5] : [1, 6];
+ const trigger = readButton(buttons, 0);
+ const squeeze = readButton(buttons, 1);
+ const primary = readButton(buttons, xrStandard ? 4 : 0);
+ const secondary = readButton(buttons, xrStandard ? 5 : 1);
+ const fireButton = anyAvailableButton(buttons, fireIndices);
+ const jumpButton = anyAvailableButton(buttons, jumpIndices);
+ const firePressed = fireButton.pressed || (
+ hasEventButton(eventButtons, 'select') && eventButtons.select
+ );
+ const jumpPressed = jumpButton.pressed || (
+ hasEventButton(eventButtons, 'squeeze') && eventButtons.squeeze
+ );
+
+ return {
+ id,
+ sourceType,
+ handedness: normalizedHandedness,
+ profiles: Array.isArray(profiles) ? [...profiles] : [],
+ targetRayMode,
+ mapping: gamepad?.mapping || 'unknown',
+ gamepad,
+ axes,
+ buttons,
+ stick: {
+ x: stick.x,
+ y: stick.y,
+ },
+ trigger,
+ squeeze,
+ primary,
+ secondary,
+ firePressed,
+ jumpPressed,
+ fireValue: Math.max(fireButton.value, trigger.value),
+ jumpValue: Math.max(jumpButton.value, squeeze.value),
+ capabilities: {
+ move: stick.available,
+ turn: stick.available,
+ fire: fireButton.available || hasEventButton(eventButtons, 'select'),
+ jump: jumpButton.available || hasEventButton(eventButtons, 'squeeze'),
+ },
+ };
+}
+
+function hasAllActions(device) {
+ return XR_REQUIRED_ACTIONS.every(action => Boolean(device?.capabilities?.[action]));
+}
+
+function missingActionsForDevices(devices) {
+ return XR_REQUIRED_ACTIONS.filter(action => !devices.some(device => device.capabilities[action]));
+}
+
+/**
+ * Decide whether one physical device or the preferred left/right pair can
+ * cover all four BZO actions. Device brand and user-agent strings are
+ * deliberately ignored: only advertised input capability matters.
+ */
+export function evaluateInputCoverage(devices = []) {
+ const availableDevices = devices.filter(Boolean);
+ if (availableDevices.length === 0) {
+ return {
+ ready: false,
+ status: 'waiting',
+ mode: null,
+ deviceId: null,
+ missing: [...XR_REQUIRED_ACTIONS],
+ reason: 'Connect a physical controller with movement, turn, fire, and jump inputs.',
+ };
+ }
+
+ const left = availableDevices.find(device => device.handedness === 'left');
+ const right = availableDevices.find(device => device.handedness === 'right');
+ const rightHasActions = Boolean(
+ right?.capabilities.move &&
+ right?.capabilities.turn &&
+ right?.capabilities.fire &&
+ right?.capabilities.jump,
+ );
+ const dualReady = Boolean(
+ left &&
+ right &&
+ (left.capabilities.move || right.capabilities.move) &&
+ (right.capabilities.turn || left.capabilities.turn) &&
+ (right.capabilities.fire || availableDevices.some(device => device.capabilities.fire)) &&
+ (right.capabilities.jump || availableDevices.some(device => device.capabilities.jump)),
+ );
+
+ if (dualReady) {
+ return {
+ ready: true,
+ status: 'ready',
+ mode: 'dual',
+ deviceId: null,
+ missing: [],
+ reason: 'Left and right controller inputs are ready.',
+ };
+ }
+
+ const single = availableDevices.find(hasAllActions);
+ if (single || rightHasActions) {
+ const device = single || right;
+ return {
+ ready: true,
+ status: 'ready',
+ mode: 'single',
+ deviceId: device.id,
+ missing: [],
+ reason: 'Single-controller input is ready.',
+ };
+ }
+
+ const missing = missingActionsForDevices(availableDevices);
+ return {
+ ready: false,
+ status: 'insufficient',
+ mode: null,
+ deviceId: null,
+ missing,
+ reason: missing.length > 0
+ ? `Required input missing: ${missing.join(', ')}.`
+ : 'The connected controllers do not cover the BZO action set.',
+ };
+}
+
+function neutralInput(coverage) {
+ return {
+ forward: 0,
+ turn: 0,
+ fire: false,
+ jump: false,
+ ready: Boolean(coverage?.ready),
+ mode: coverage?.mode || null,
+ };
+}
+
+/** Resolve semantic devices into BZO actions while preserving upstream keybinds. */
+export function resolveSemanticInput(devices = [], coverage = evaluateInputCoverage(devices)) {
+ const input = neutralInput(coverage);
+ if (!coverage.ready) return input;
+
+ if (coverage.mode === 'dual') {
+ const left = devices.find(device => device.handedness === 'left');
+ const right = devices.find(device => device.handedness === 'right');
+ const rightStick = right?.capabilities.move ? right.stick : null;
+ const leftStick = left?.capabilities.move ? left.stick : null;
+ const rightY = rightStick?.y || 0;
+ const rightX = rightStick?.x || 0;
+ input.forward = -((Math.abs(rightY) > 0 ? rightY : leftStick?.y) || 0);
+ input.turn = -((Math.abs(rightX) > 0 ? rightX : leftStick?.x) || 0);
+
+ const fireDevice = right?.capabilities.fire
+ ? right
+ : devices.find(device => device.capabilities.fire);
+ const jumpDevice = right?.capabilities.jump
+ ? right
+ : devices.find(device => device.capabilities.jump);
+ input.fire = Boolean(fireDevice?.firePressed);
+ input.jump = Boolean(jumpDevice?.jumpPressed);
+ return input;
+ }
+
+ const device = devices.find(candidate => candidate.id === coverage.deviceId) || devices.find(hasAllActions);
+ if (!device) return neutralInput({ ready: false });
+ input.forward = -device.stick.y;
+ input.turn = -device.stick.x;
+ input.fire = device.firePressed;
+ input.jump = device.jumpPressed;
+ return input;
+}
+
+export function describeDevice(device) {
+ return {
+ id: device.id,
+ sourceType: device.sourceType,
+ handedness: device.handedness,
+ profiles: [...device.profiles],
+ targetRayMode: device.targetRayMode,
+ mapping: device.mapping,
+ capabilities: { ...device.capabilities },
+ };
+}
diff --git a/public/webxr.js b/public/webxr.js
index 2d64ab7..d5744ea 100644
--- a/public/webxr.js
+++ b/public/webxr.js
@@ -7,14 +7,28 @@
// WebXR Manager for VR/AR support (Quest 2, Viture Luma Ultra, etc.)
+import {
+ createSemanticDevice,
+ describeDevice,
+ evaluateInputCoverage,
+ resolveSemanticInput,
+} from './webxr-capabilities.mjs';
+
let xrSession = null;
let xrSupported = false;
let xrEnabled = false;
let xrMode = null; // 'immersive-vr' or 'immersive-ar'
let xrInputSources = new Map(); // Map of controller input source ID -> controller state
+let xrEventButtons = new Map(); // Map of input source -> select/squeeze event state
let xrSessionLifecycle = null;
let xrStartPromise = null;
let xrEndPromise = null;
+let semanticDevices = [];
+let inputCoverage = evaluateInputCoverage();
+let nextInputSourceId = 1;
+let xrInputSourceIds = new Map();
+
+const DEFAULT_INPUT_MESSAGE = 'Connect a physical controller with movement, turn, fire, and jump inputs.';
export const xrState = {
enabled: false,
@@ -22,6 +36,13 @@ export const xrState = {
headPose: null, // { position: THREE.Vector3, quaternion: THREE.Quaternion }
controllers: new Map(), // input source ID -> { pose, grip, select }
frameCounter: 0,
+ visibilityState: 'visible',
+ inputReady: false,
+ inputStatus: 'waiting',
+ inputMessage: DEFAULT_INPUT_MESSAGE,
+ inputMode: null,
+ inputDevices: [],
+ inputStatusKey: '',
};
// Send debug message through app-wide logger in client.js
@@ -31,6 +52,74 @@ export function debugLog(message) {
}
}
+function getInputSourceId(inputSource) {
+ if (!inputSource || typeof inputSource !== 'object') return `device-${nextInputSourceId++}`;
+ if (!xrInputSourceIds.has(inputSource)) {
+ xrInputSourceIds.set(inputSource, `xr-${nextInputSourceId++}`);
+ }
+ return xrInputSourceIds.get(inputSource);
+}
+
+function getEventButtons(inputSource) {
+ if (!inputSource || typeof inputSource !== 'object') return null;
+ let buttons = xrEventButtons.get(inputSource);
+ if (!buttons) {
+ buttons = { select: false, squeeze: false };
+ xrEventButtons.set(inputSource, buttons);
+ }
+ return buttons;
+}
+
+function setEventButton(inputSource, name, pressed) {
+ const buttons = getEventButtons(inputSource);
+ if (buttons && (name === 'select' || name === 'squeeze')) {
+ buttons[name] = Boolean(pressed);
+ }
+}
+
+function clearEventButtons() {
+ xrEventButtons.forEach(buttons => {
+ buttons.select = false;
+ buttons.squeeze = false;
+ });
+}
+
+function publishInputStatus() {
+ const status = inputCoverage;
+ const nextStatus = `${status.status}:${status.mode || ''}:${status.reason}`;
+ const changed = nextStatus !== xrState.inputStatusKey;
+
+ xrState.inputReady = Boolean(status.ready);
+ xrState.inputStatus = status.status;
+ xrState.inputMessage = status.reason;
+ xrState.inputMode = status.mode;
+ xrState.inputDevices = semanticDevices.map(describeDevice);
+ xrState.inputStatusKey = nextStatus;
+
+ if (changed && typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
+ const detail = {
+ ready: xrState.inputReady,
+ status: xrState.inputStatus,
+ message: xrState.inputMessage,
+ mode: xrState.inputMode,
+ missing: [...(status.missing || [])],
+ devices: xrState.inputDevices,
+ };
+ if (typeof window.CustomEvent === 'function') {
+ window.dispatchEvent(new window.CustomEvent('webxrinputchange', { detail }));
+ }
+ }
+}
+
+function publishSessionState(enabled) {
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return;
+ if (typeof window.CustomEvent === 'function') {
+ window.dispatchEvent(new window.CustomEvent('webxrsessionchange', {
+ detail: { enabled: Boolean(enabled), mode: xrMode },
+ }));
+ }
+}
+
// Check if WebXR is available
async function checkXRSupport() {
debugLog('Checking XR support... navigator.xr=' + (navigator.xr ? 'YES' : 'NO'));
@@ -131,6 +220,9 @@ async function startXRSession(renderer, animationCallback) {
xrEnabled = true;
xrState.enabled = true;
+ xrState.visibilityState = session.visibilityState || 'visible';
+ publishInputStatus();
+ publishSessionState(true);
// Set up the XR animation loop
if (animationCallback) {
@@ -147,6 +239,7 @@ async function startXRSession(renderer, animationCallback) {
} else {
resetXRState();
}
+ publishSessionState(false);
return false;
}
}
@@ -159,6 +252,10 @@ function createXRSessionLifecycle(session, renderer) {
endRequested: false,
inputSourcesChangeHandler: null,
visibilityChangeHandler: null,
+ selectStartHandler: null,
+ selectEndHandler: null,
+ squeezeStartHandler: null,
+ squeezeEndHandler: null,
endHandler: null,
};
@@ -173,6 +270,7 @@ function createXRSessionLifecycle(session, renderer) {
event.added?.forEach(inputSource => {
addXRInputSource(inputSource);
});
+ updateXRControllerInput();
};
lifecycle.visibilityChangeHandler = () => {
@@ -184,9 +282,22 @@ function createXRSessionLifecycle(session, renderer) {
xrState.headPose = null;
resetXRControllerStates();
xrState.controllers.clear();
+ clearEventButtons();
+ xrState.visibilityState = 'hidden';
+ semanticDevices = [];
+ inputCoverage = evaluateInputCoverage();
+ publishInputStatus();
+ } else {
+ xrState.visibilityState = session.visibilityState || 'visible';
+ updateXRControllerInput();
}
};
+ lifecycle.selectStartHandler = event => setEventButton(event.inputSource, 'select', true);
+ lifecycle.selectEndHandler = event => setEventButton(event.inputSource, 'select', false);
+ lifecycle.squeezeStartHandler = event => setEventButton(event.inputSource, 'squeeze', true);
+ lifecycle.squeezeEndHandler = event => setEventButton(event.inputSource, 'squeeze', false);
+
lifecycle.endHandler = () => {
if (xrSessionLifecycle !== lifecycle) {
return;
@@ -199,6 +310,10 @@ function createXRSessionLifecycle(session, renderer) {
session.addEventListener('inputsourceschange', lifecycle.inputSourcesChangeHandler);
session.addEventListener('visibilitychange', lifecycle.visibilityChangeHandler);
+ session.addEventListener('selectstart', lifecycle.selectStartHandler);
+ session.addEventListener('selectend', lifecycle.selectEndHandler);
+ session.addEventListener('squeezestart', lifecycle.squeezeStartHandler);
+ session.addEventListener('squeezeend', lifecycle.squeezeEndHandler);
session.addEventListener('end', lifecycle.endHandler);
setupXRInput(session);
@@ -245,6 +360,10 @@ function cleanupXRSession(lifecycle, session) {
if (lifecycle) {
session.removeEventListener('inputsourceschange', lifecycle.inputSourcesChangeHandler);
session.removeEventListener('visibilitychange', lifecycle.visibilityChangeHandler);
+ session.removeEventListener('selectstart', lifecycle.selectStartHandler);
+ session.removeEventListener('selectend', lifecycle.selectEndHandler);
+ session.removeEventListener('squeezestart', lifecycle.squeezeStartHandler);
+ session.removeEventListener('squeezeend', lifecycle.squeezeEndHandler);
session.removeEventListener('end', lifecycle.endHandler);
if (typeof lifecycle.renderer?.setAnimationLoop === 'function') {
@@ -266,8 +385,21 @@ function resetXRState() {
xrState.enabled = false;
xrState.headPose = null;
xrState.frameCounter = 0;
+ xrState.visibilityState = 'visible';
+ xrState.inputReady = false;
+ xrState.inputStatus = 'waiting';
+ xrState.inputMessage = DEFAULT_INPUT_MESSAGE;
+ xrState.inputMode = null;
+ xrState.inputDevices = [];
+ xrState.inputStatusKey = '';
xrInputSources.clear();
+ xrEventButtons.clear();
+ xrInputSourceIds.clear();
xrState.controllers.clear();
+ semanticDevices = [];
+ inputCoverage = evaluateInputCoverage();
+ publishInputStatus();
+ publishSessionState(false);
}
// Store reference to reset animation loop
@@ -318,6 +450,12 @@ function createXRControllerState(inputSource) {
}
function addXRInputSource(inputSource) {
+ if (!inputSource) {
+ return;
+ }
+
+ getInputSourceId(inputSource);
+ getEventButtons(inputSource);
const handedness = inputSource?.handedness;
if (!handedness) {
return;
@@ -329,6 +467,12 @@ function addXRInputSource(inputSource) {
}
function removeXRInputSource(inputSource) {
+ if (!inputSource) {
+ return;
+ }
+
+ xrEventButtons.delete(inputSource);
+ xrInputSourceIds.delete(inputSource);
const handedness = inputSource?.handedness;
if (!handedness) {
return;
@@ -388,6 +532,60 @@ function setupXRInput(session = xrSession) {
}
}
+function collectStandardGamepads() {
+ if (typeof navigator === 'undefined' || typeof navigator.getGamepads !== 'function') {
+ return [];
+ }
+
+ try {
+ return Array.from(navigator.getGamepads() || []).filter(gamepad => (
+ gamepad && gamepad.connected !== false
+ ));
+ } catch (err) {
+ debugLog('Could not read standard Gamepad API: ' + err.message);
+ return [];
+ }
+}
+
+function collectSemanticDevices() {
+ const devices = [];
+ const xrGamepads = new Set();
+
+ for (const inputSource of xrSession?.inputSources || []) {
+ if (inputSource?.gamepad) {
+ xrGamepads.add(inputSource.gamepad);
+ }
+
+ devices.push(createSemanticDevice({
+ id: getInputSourceId(inputSource),
+ sourceType: 'xr',
+ handedness: inputSource?.handedness || 'none',
+ profiles: inputSource?.profiles || [],
+ targetRayMode: inputSource?.targetRayMode || null,
+ gamepad: inputSource?.gamepad || null,
+ eventButtons: xrEventButtons.get(inputSource) || null,
+ }));
+ }
+
+ for (const gamepad of collectStandardGamepads()) {
+ // An XRInputSource already represents this controller. Avoid counting it
+ // twice when a runtime also exposes it through navigator.getGamepads().
+ if (xrGamepads.has(gamepad) || gamepad.mapping === 'xr-standard') {
+ continue;
+ }
+
+ const gamepadId = gamepad.index ?? gamepad.id ?? devices.length;
+ devices.push(createSemanticDevice({
+ id: `gamepad-${gamepadId}`,
+ sourceType: 'gamepad',
+ handedness: 'none',
+ gamepad,
+ }));
+ }
+
+ return devices;
+}
+
// Update XR controller input each frame
export function updateXRControllerInput() {
if (!xrSession || !xrEnabled) {
@@ -397,9 +595,15 @@ export function updateXRControllerInput() {
if (xrSession.visibilityState === 'hidden') {
resetXRControllerStates();
xrState.controllers.clear();
+ clearEventButtons();
+ xrState.visibilityState = 'hidden';
+ semanticDevices = [];
+ inputCoverage = evaluateInputCoverage();
+ publishInputStatus();
return;
}
+ xrState.visibilityState = xrSession.visibilityState || 'visible';
const frameCounter = xrState.frameCounter || 0;
xrState.frameCounter = frameCounter + 1;
const activeHandedness = new Set();
@@ -483,6 +687,10 @@ export function updateXRControllerInput() {
xrInputSources.delete(handedness);
}
}
+
+ semanticDevices = collectSemanticDevices();
+ inputCoverage = evaluateInputCoverage(semanticDevices);
+ publishInputStatus();
}
@@ -520,6 +728,38 @@ export function getXRControllerInput() {
return input;
}
+// New gameplay code consumes semantic actions so the upstream Quest bindings
+// stay centralized and a partially connected controller cannot leave stale
+// movement or button state active.
+export function getXRActionInput() {
+ if (!xrState.enabled || xrState.visibilityState === 'hidden') {
+ return {
+ forward: 0,
+ turn: 0,
+ fire: false,
+ jump: false,
+ ready: false,
+ mode: null,
+ };
+ }
+ return resolveSemanticInput(semanticDevices, inputCoverage);
+}
+
+export function getXRInputStatus() {
+ return {
+ ready: xrState.inputReady,
+ status: xrState.inputStatus,
+ message: xrState.inputMessage,
+ mode: xrState.inputMode,
+ missing: [...(inputCoverage.missing || [])],
+ devices: xrState.inputDevices.map(device => ({
+ ...device,
+ profiles: [...device.profiles],
+ capabilities: { ...device.capabilities },
+ })),
+ };
+}
+
// Export API
export async function initXR() {
return await checkXRSupport();
@@ -545,3 +785,11 @@ export async function toggleXRSession(renderer, animationCallback) {
export function isXREnabled() {
return xrEnabled;
}
+
+export function isXRInputReady() {
+ return Boolean(
+ xrState.enabled &&
+ xrState.inputReady &&
+ xrState.visibilityState !== 'hidden'
+ );
+}
diff --git a/scripts/test-webxr-capabilities.mjs b/scripts/test-webxr-capabilities.mjs
new file mode 100644
index 0000000..34a4080
--- /dev/null
+++ b/scripts/test-webxr-capabilities.mjs
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/*
+ * Copyright (C) 2025-2026 Tim Riker
+ * Licensed under the GNU Affero General Public License v3.0.
+ * Source: https://github.com/timriker/bzo
+ * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html
+ */
+
+import assert from 'node:assert/strict';
+import {
+ applyDeadzone,
+ createSemanticDevice,
+ describeDevice,
+ evaluateInputCoverage,
+ resolveSemanticInput,
+} from '../public/webxr-capabilities.mjs';
+
+const button = (pressed = false, value = pressed ? 1 : 0) => ({ pressed, value });
+const closeTo = (actual, expected) => assert.ok(Math.abs(actual - expected) < 0.000001);
+
+assert.equal(applyDeadzone(0.1), 0);
+closeTo(applyDeadzone(1), 1);
+assert.ok(applyDeadzone(-0.5) < 0);
+
+const left = createSemanticDevice({
+ id: 'left-xr',
+ sourceType: 'xr',
+ handedness: 'left',
+ profiles: ['oculus-touch-v3'],
+ targetRayMode: 'tracked-pointer',
+ gamepad: {
+ mapping: 'xr-standard',
+ axes: [0.9, 0.8, 0.4, -0.6],
+ buttons: [button(), button(), button(), button(), button(), button()],
+ },
+});
+
+const right = createSemanticDevice({
+ id: 'right-xr',
+ sourceType: 'xr',
+ handedness: 'right',
+ profiles: ['oculus-touch-v3'],
+ targetRayMode: 'tracked-pointer',
+ gamepad: {
+ mapping: 'xr-standard',
+ axes: [0.1, 0.1, -0.7, 0.2],
+ buttons: [button(true), button(true), button(), button(), button(true), button(true)],
+ },
+});
+
+const dualCoverage = evaluateInputCoverage([left, right]);
+assert.equal(dualCoverage.ready, true);
+assert.equal(dualCoverage.mode, 'dual');
+const dualInput = resolveSemanticInput([left, right], dualCoverage);
+// Upstream keybind: the right stick is primary for both movement and turning.
+closeTo(dualInput.forward, -0.05882352941176472);
+closeTo(dualInput.turn, 0.6470588235294117);
+assert.equal(dualInput.fire, true);
+assert.equal(dualInput.jump, true);
+
+const bluetoothGamepad = createSemanticDevice({
+ id: 'bluetooth-xbox-like',
+ sourceType: 'gamepad',
+ handedness: 'none',
+ gamepad: {
+ mapping: 'standard',
+ axes: [0.25, -0.8],
+ buttons: [button(true), button(true), button(), button(), button(), button(), button(true), button(true)],
+ },
+});
+const singleCoverage = evaluateInputCoverage([bluetoothGamepad]);
+assert.equal(singleCoverage.ready, true);
+assert.equal(singleCoverage.mode, 'single');
+const singleInput = resolveSemanticInput([bluetoothGamepad], singleCoverage);
+closeTo(singleInput.forward, 0.7647058823529411);
+closeTo(singleInput.turn, -0.11764705882352941);
+assert.equal(singleInput.fire, true);
+assert.equal(singleInput.jump, true);
+
+const eventButtons = createSemanticDevice({
+ id: 'event-buttons',
+ sourceType: 'xr',
+ handedness: 'right',
+ gamepad: {
+ mapping: 'xr-standard',
+ axes: [0, 0, 0, 0],
+ buttons: [button(), button(), button(), button(), button(), button()],
+ },
+ eventButtons: { select: true, squeeze: true },
+});
+assert.equal(eventButtons.firePressed, true);
+assert.equal(eventButtons.jumpPressed, true);
+
+const incomplete = createSemanticDevice({
+ id: 'incomplete',
+ sourceType: 'xr',
+ handedness: 'right',
+ gamepad: { mapping: 'xr-standard', axes: [0, 0, 0, 0], buttons: [] },
+});
+const incompleteCoverage = evaluateInputCoverage([incomplete]);
+assert.equal(incompleteCoverage.ready, false);
+assert.equal(incompleteCoverage.status, 'insufficient');
+assert.deepEqual(incompleteCoverage.missing, ['fire', 'jump']);
+
+assert.deepEqual(describeDevice(bluetoothGamepad), {
+ id: 'bluetooth-xbox-like',
+ sourceType: 'gamepad',
+ handedness: 'none',
+ profiles: [],
+ targetRayMode: null,
+ mapping: 'standard',
+ capabilities: { move: true, turn: true, fire: true, jump: true },
+});
+
+console.log('WebXR capability tests passed');