VR polish checklist — game feel that doesn't make people sick
The difference between "it works" and "this is great" is game feel. But the standard 2D/flat-3D juice playbook is dangerous in VR. Half of those techniques work by moving or flooding the camera — which in VR means moving or flooding the player's head and eyes. That causes discomfort and nausea. This skill is the VR-safe replacement: keep the same impact, deliver it through the hands, the objects, and the soundscape — never through the camera.
Core principle: the game NEVER moves, shakes, or floods the player's view. Impact is delivered to objects, hands (haptics), and ears — not the head.
The hard rules — never do these in VR
Violating the letter of these is violating the spirit. There is no "just a little" exception.
| ❌ Forbidden (causes nausea) | ✅ VR-safe replacement |
|---|---|
| Camera / screen shake | Haptic pulse + object shake (shake the hit object, not the view) |
| Hit-stop / freeze-frame (pausing the world) | Brief slow-mo of the target only, or emissive flash + haptic — never freeze the whole view |
| Full-screen flash / color tint / vignette-as-feedback | Local emissive flash on the object; a world-space damage indicator near the source |
| Camera moves / dolly / forced look / cutscene takeover | Let the player keep their head; draw attention with light, sound, motion in the world |
| Chromatic aberration / heavy post FX | Skip; rarely worth the per-eye cost and can disorient (environment-scaffold) |
| Sudden FOV changes | Only reduce FOV (comfort vignette) during locomotion, never as juice |
If a polish idea works by changing what the whole view does, it's out. Shake the object, buzz the hand, play the sound.
VR-safe juice — apply these
1. Haptics (the VR version of screen shake)
A haptic pulse on the controller is the most direct impact feedback VR has. Scale intensity to impact.
function pulse(controller: THREE.Object3D, intensity = 0.5, ms = 40) {
const src = controller.userData.inputSource as XRInputSource | undefined;
(src?.gamepad as any)?.hapticActuators?.[0]?.pulse?.(intensity, ms);
}
// small tick = pulse(0.2, 20); solid hit = pulse(0.8, 60)
2. Emissive flash on the hit object (the VR "flash white")
function flashEmissive(mesh: THREE.Mesh, hex = 0xffffff, dur = 0.1) {
const m = mesh.material as THREE.MeshStandardMaterial;
if (!m.emissive) return;
const original = m.emissive.getHex();
m.emissive.setHex(hex);
setTimeout(() => m.emissive.setHex(original), dur * 1000);
}
3. Object shake / squash & stretch (move the OBJECT, never the camera)
// Brief positional jitter on the hit object — the safe analogue of screen shake.
function shakeObject(obj: THREE.Object3D, amt = 0.03, ms = 150) {
const base = obj.position.clone();
const start = performance.now(); // (or pass time in from the loop)
const tick = () => {
const k = 1 - (performance.now() - start) / ms;
if (k <= 0) { obj.position.copy(base); return; }
obj.position.set(base.x + (Math.random() - 0.5) * amt * k,
base.y + (Math.random() - 0.5) * amt * k,
base.z + (Math.random() - 0.5) * amt * k);
requestAnimationFrame(tick);
};
tick();
}
// Squash on impact: briefly scale opposite axes, ease back (jump: y*1.2, xz*0.85).
4. Spatial sound on every action
Every important event has a sound, emitted from its world position with slight pitch variation. See spatial-audio. Silent VR feels dead.
5. Frame-rate-independent easing
// Damp toward a target so motion isn't robotic; stable across 72/90/120 Hz.
obj.position.lerp(target, 1 - Math.exp(-dt * speed));
6. World-space feedback for state
Score pops, "+10" labels, cooldown rings — place them in the world near the source (a small Mesh with a CanvasTexture that drifts up and fades), never as a 2D HUD overlay glued to the eyes (world-space-ui).
7. Comfortable transitions
For scene/level changes, fade to black (a large sphere around the head whose material opacity animates) rather than cutting or moving the camera. Never spin or dolly the player to transition.
Domain principles honored here
- Comfort first — the entire rule set exists to protect the vestibular system; impact never touches the camera.
- Diegetic — feedback lives on objects and in space, not as screen overlays.
- Input abstraction — haptics go through the standard input-source gamepad.
Red flags — STOP if you catch yourself doing this
- Reaching for
camera.positionto add shake → shake the object instead. - Pausing
update()for hit-stop → slow only the target, or drop it. - Adding a full-screen overlay
<div>/ fullscreen quad for a flash or damage tint → local emissive flash. - "It's only a tiny camera nudge" → still moves the head. No.
- Adding a fade/transition by moving or rotating the player → fade opacity instead.
Verify
Walk every item before declaring done (skip only if the game genuinely can't use it):
- No camera/screen shake anywhere. (grep for
camera.positionwrites outside the XR rig) - No hit-stop that freezes the whole view.
- No full-screen flash, tint, or feedback vignette. (comfort vignette during locomotion is separate and allowed)
- The game never moves or rotates the player's head.
- Impacts fire a haptic pulse scaled to force.
- Hit objects use
flashEmissiveand/or object shake/squash. - Every important action has a spatial sound with pitch variation (
spatial-audio). - Visible motion uses easing/damping, not linear.
- Score/damage feedback is world-space, near the source (
world-space-ui). - Scene transitions fade to black; they never dolly or spin the player.
- On-device comfort pass: a VR-naïve tester plays for 5 minutes without discomfort.
References
spatial-audio— sound feedback from world positions.vr-grab-interaction— haptics on grab/throw.world-space-ui— world-space score/damage indicators instead of HUD overlays.comfort-locomotion— the one place a comfort vignette/FOV reduction is appropriate.vfx-and-particles— local particle bursts for impact (not full-screen effects).