npm install, then start the development server with npm run dev. The server runs on port 8081 by default. If you have a VR headset, open the URL in the headset’s browser and press the VR button to enter the experience. If you are developing on desktop, the sample automatically activates built-in WebXR emulation so you can test without a headset.| File | What it demonstrates | Key concepts |
|---|---|---|
src/index.tsx | Application entry point and scene composition | XR store configuration, Canvas setup, controller component mapping, VR button |
src/gun.tsx | Controller-attached blaster component | Controller button events, haptic feedback, positional audio, spawning game objects |
src/bullets.tsx | Bullet state management and rendering | Zustand store patterns, frame-based animation, proximity collision detection |
src/targets.tsx | Target spawning and positioning | glTF cloning with useMemo, global object registry with Set<Object3D> |
src/score.tsx | Score tracking and display | Zustand state updates, 3D text rendering with drei |
src/assets/ | Game resources | glTF models (blaster, spacestation, target), audio files, fonts |
Gun component to the right controller. When you enter VR, the Gun component renders inside the controller’s transform hierarchy, inheriting its position and rotation automatically.const xrStore = createXRStore({
emulate: { controller: { left: {...}, right: {...} } },
controller: { right: Gun },
});
Gun component uses useXRControllerButtonEvent to respond when you press the trigger. The handler spawns a bullet, plays audio, and triggers haptic feedback through the native WebXR Gamepad API.useXRControllerButtonEvent(state, "xr-standard-trigger", (state) => {
if (state === "pressed") { /* spawn bullet, play sound, haptics */ }
});
gamepad.hapticActuators[0]?.pulse(0.6, 100) directly rather than relying on a wrapper library. See src/gun.tsx for the full button handler implementation.addBullet and removeBullet actions, but the Gun component accesses these imperatively via getState() rather than subscribing to reactive updates. This pattern keeps the spawn logic synchronous and avoids triggering unnecessary re-renders.useBulletStore.getState().addBullet(worldPos, worldQuat);
Bullet component reads its own data from the array. See src/bullets.tsx for the store definition and src/gun.tsx for imperative dispatch.Bullet component runs collision checks inside useFrame, iterating over a global Set of target objects exported from the targets module. When a bullet’s distance to a target falls below 1 meter, the bullet removes itself, animates the target, and increments the score.const distance = target.position.distanceTo(bulletObject.position);
if (distance < 1) { /* remove bullet, animate target, add score */ }
<Gltf src="..."> component. The bullet prototype extracts a sub-object from the blaster model using useGLTF and scene.getObjectByName(). Targets call scene.clone() inside useMemo to create independent instances that can be animated separately.const { scene } = useGLTF("assets/target.glb");
const target = useMemo(() => scene.clone(), []);
gsap.ticker.tick() inside a minimal component that uses useFrame.const GsapTicker = () => {
useFrame(() => { gsap.ticker.tick(); });
return null;
};
Gun component mapped to the left controller to enable dual-wielding. Modify the bullet spawn logic to fire multiple projectiles in a spread pattern, or add a ricochet behavior by detecting collisions with the space station geometry and reflecting the bullet’s direction vector.