Skip to main content

Design and decisions

Summary

  • @realitycollective/webxr-input stays a single conceptual thing: types and tiny pure helper functions, nothing else
  • no engine import and no runtime dependency, ever, enforced by an architecture test rather than a guideline
  • capabilities are derived facts about the live session, not requests an app makes
  • the package evolves additively; a breaking change is deliberately expensive
  • it is deliberately the slowest-moving package in the Reality Collective WebXR stack

Key decisions

1. Engine-free by construction, not by convention

The package holds only types and pure helper functions.

Why: an architecture test scans every file under src/ for imports of three, @iwsdk/*, @pmndrs/* and xrblocks, and fails the build if one appears; a second check parses package.json and fails if dependencies or peerDependencies is non-empty. Together they are what let the Interactions family, and the UI Extensions family once it adopts this package, depend on it from any engine with no risk of an engine leaking in transitively.

2. Adapters re-export the contracts; apps never install them directly

Every engine adapter re-exports this package in full.

Why: keeps an app's install to exactly one package, and means picking up a new capability is "update the adapter", not "add a second dependency".

3. Capabilities are derived facts, not requests

InputCapabilities describes what the live session can deliver; a provider derives it from the session, never from what an app asked for or hoped to get.

Why: a session can change under an app, hands can swap for controllers, or a session can start or end, and capabilities have to reflect that truthfully, re-published whenever it happens.

4. The requirement list is tested against the capability keys

INPUT_CAPABILITY_REQUIREMENTS is written as runtime data specifically so a test can check it against the keys of NO_CAPABILITIES.

Why: stops a capability being added to InputCapabilities in one place only, with the requirement list left to drift out of step.

5. Grab is a three-mode fulfilment contract, not a boolean

GrabCapability is "none", "poseOnly" or "native", rather than a single boolean flag.

Why: matches what engines actually offer, such as IWSDK's own physics-driven grab against an adapter that can only report poses, rather than forcing every engine into one shape.

6. Snapshot ownership is a hard, tested rule

A snapshot belongs to whoever sample() handed it to; a provider builds fresh objects each call and never mutates one it has already returned.

Why: lets a consumer keep last frame's grip pose for a velocity tracker without defensive copying. The conformance suite has a dedicated case that catches a provider that pools objects and refills them in place instead.

7. Pointer streams are shaped to match UI Extensions

PointerSample and PointerInputSource are structurally identical to the pointer contract in @realitycollective/webxr-uiextensions.

Why: lets one engine adapter drive both the interaction family's behaviours and the UI Extensions family's windowing from the same pointer stream, rather than each family needing its own.

8. Evolve additively; a breaking change is deliberately expensive

New fields and capabilities arrive as optional; a breaking change requires checking every consuming family first.

Why: keeps this package's shapes the one thing an adapter author can rely on not moving under them, which is the entire point of extracting a contracts package in the first place.

9. The conformance suite ships as data, not as fixed tests

inputProviderContractCases() returns case objects, each a name and a run(provider, driver?) function, rather than a fixed test file.

Why: every adapter repository already has its own test runner, so the suite stays runner-free and each adapter iterates it with whichever runner it already uses.

Package breakdown

This family is a single package.

@realitycollective/webxr-input

src/index.ts re-exports six files, each covering a distinct part of the contract.

FileExports
types.tsVec3Tuple, QuatTuple, PoseTuple, RayTuple, HeadPose, HeadPoseSource, Unsubscribe
capabilities.tsInputCapabilities, GrabCapability, NO_CAPABILITIES, INPUT_CAPABILITY_REQUIREMENTS, InputCapabilityRequirement, satisfies, unmetRequirements
source.tsInputSourceSnapshot, Handedness, InputSourceKind, PresenceModality, SELECT_PRESS_THRESHOLD, SELECT_RELEASE_THRESHOLD
provider.tsInputProvider, InputHitHint
pointer.tsPointerSample, PointerInputSource
velocity.tsvelocityBetween
contract-cases.tsinputProviderContractCases, InputProviderContractCase, InputProviderContractDriver

Code layout

WebXR-Input/
├── packages/
│ └── webxr-input/ @realitycollective/webxr-input - the contracts
│ ├── src/ types, capabilities, provider, pointer streams
│ └── test/ contract tests + the engine-free architecture gate
├── scripts/ shared release tooling (set-version, verify-pack)
└── .github/workflows/ ci.yml + publish-npm.yml

What is deliberately not built

  • No engine code, ever. This is a permanent constraint enforced by the architecture test, not a temporary gap waiting to close.
  • No runtime dependencies of any kind, so depending on this package can never pull anything else into a consumer's tree.
  • No scene or content model. Portable world-building, meshes, prefabs and placement, is left to the app; this package covers input only.
  • No claim of permanence as a standard. If an engine-free equivalent emerges upstream, or spec convergence makes the remaining gap trivial, the plan is to adopt or retire this package, not defend its existence.

More information