Game feel
The feel folder (src/engine/feel/, exported from @/engine) makes moments in the 3D scene land: a hit that shakes the camera and freezes the scene for 60 ms, a landing that squashes the character, a death in slow motion. Every tool is a plain function you call from gameplay code, with no React state and nothing to pass down. For menus and the HUD, use the UI kit's motion pieces instead (HUD and UI).
Smallest working example
Mount GameFeel once inside the canvas and FeelOverlay once over it, then call the tools from anywhere:
import React, { useRef } from 'react';
import { Pressable, Text, View } from 'react-native';
import type * as THREE from 'three';
import { EngineCanvas, FeelOverlay, GameFeel, juice, type GameScreenProps } from '@/engine';
export const Screen = (_props: GameScreenProps) => {
const enemy = useRef<THREE.Mesh>(null);
const onHit = () => juice('hit', { object3d: enemy.current ?? undefined });
return (
<View style={{ flex: 1 }}>
<EngineCanvas>
<GameFeel />
<mesh ref={enemy} position={[0, 1, 0]}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#FF5C7A" />
</mesh>
</EngineCanvas>
<FeelOverlay />
<Pressable accessibilityRole="button" onPress={onHit} style={{ position: 'absolute', bottom: 48, alignSelf: 'center', padding: 16 }}>
<Text style={{ color: '#FFFFFF', fontWeight: '800' }}>HIT</Text>
</Pressable>
</View>
);
};
juice('hit') adds camera shake, a 60 ms hit-stop, a white flash, a squash on the object and a medium haptic.
Setup components
GameFeel
Mount once inside EngineCanvas. It installs a FeelClock as R3F's clock, so the delta of every useFrame (physics, particles, the character controller, animations, tweens) is scaled by the current slow motion or hit-stop. It steps the shared tweens and the shake, and applies the shake to the camera right before the draw and removes it right after, so every camera rig keeps its own position and damping.
| Prop | Type | Default | What it does |
|---|---|---|---|
shake | ShakeOptions | see below | Tune the camera shake |
timeScale | number | 1 | A permanent time scale under the timed effects (0.5 is half speed) |
ShakeOptions: maxOffset (metres at full trauma, 0.35), maxRoll (radians, 0.06), decay (trauma lost per second, 1.6, so a full shake lasts about 0.6 s), frequency (cycles per second, 28). <GameFeel shake={{ maxOffset: 0 }} /> turns shake off, for a settings toggle.
FeelOverlay
Mount once over the canvas (after EngineCanvas, before the HUD). It is the full-screen layer flash() draws on, and it wires haptics (expo-haptics) for the juice presets. With the system's reduced motion setting on, flashes are shorter and dimmer, never off.
The tools
| Function | What it does |
|---|---|
shake(amount) | Add trauma: 0.2 a bump, 0.5 a crash, 0.9 an explosion. Adds up, capped at 1. The offset grows with trauma squared, so small values barely move the camera |
hitStop(ms = 60) | Freeze the scene for real milliseconds. 40 to 90 ms reads as impact; more reads as a stall. The HUD keeps moving |
slowMotion(scale, ms) | The scene at scale speed for real milliseconds. When effects overlap, the strongest (lowest) scale wins |
flash(color = '#ffffff', ms = 120, alpha = 0.4) | A full-screen colour flash that fades over ms. Needs FeelOverlay |
squash(object, amount = 0.25, duration = 0.22) | Wider and shorter, then back (landings) |
stretch(object, amount = 0.2, duration = 0.25) | Taller and thinner, then back (jumps, launches) |
pop(object, amount = 0.3, duration = 0.3) | Scale up, then settle (a pickup appearing) |
vanish(object, duration = 0.25) | Shrink to nothing; returns a promise that resolves when gone |
object is any three Object3D (a mesh, a group, CharacterMesh's group).
juice(name, overrides?)
The presets combine the tools. Override any field per call, and pass object3d for the object effects:
import type * as THREE from 'three';
import { juice } from '@/engine';
export function onEnemyHit(enemyMesh: THREE.Object3D) {
juice('hit', { object3d: enemyMesh, shake: 0.8, hitStop: 0 });
}
export function onBarrelExploded() {
juice('explosion');
}
| Name | Shake | Hit-stop | Flash | Slow motion | Object | Haptic |
|---|---|---|---|---|---|---|
bump | 0.2 | light | ||||
land | 0.12 | squash | light | |||
pickup | white, 90 ms, 0.12 | vanish | light | |||
hit | 0.45 | 60 ms | white, 90 ms, 0.35 | squash | medium | |
explosion | 0.9 | 80 ms | amber, 180 ms, 0.5 | 0.45 for 260 ms | heavy | |
death | 0.6 | red, 320 ms, 0.5 | 0.25 for 900 ms | squash | heavy |
A JuiceRecipe has shake?, hitStop?, flash?: { color, ms, alpha }, slow?: { scale, ms }, object? ('squash', 'stretch', 'pop', 'vanish') and haptic? ('light', 'medium', 'heavy'). JUICE is the table.
Tweens
tweens is the shared tween runner. It drives any number fields of any object: a Vector3, an Euler, a Color, a material, a light, or your own { hp }.
import { tweens } from '@/engine';
import type * as THREE from 'three';
export function openDoor(door: THREE.Object3D, onOpen: () => void) {
tweens.sequence([
[door.rotation, { y: -1.4 }, { duration: 0.6, ease: 'cubicInOut' }],
onOpen,
[door.rotation, { y: 0 }, { duration: 0.8, delay: 1 }],
]);
}
export function fadeOut(material: THREE.MeshStandardMaterial, done: () => void) {
return tweens.to(material, { opacity: 0 }, { duration: 0.3, onComplete: done });
}
| Function | What it does |
|---|---|
tweens.to(target, props, options?) | Animate the fields to these values |
tweens.from(target, props, options?) | Start from these values and animate back to the current ones |
tweens.sequence(steps) | Steps in order; a step is [target, props, options?] or a function to call at that point |
tweens.cancelAll() | Stop every shared tween |
TweenOptions:
| Option | Default | What it does |
|---|---|---|
duration | 0.3 | Seconds |
ease | 'cubicOut' | An EASE name or your own function |
delay | 0 | Seconds before it starts |
yoyo | false | Go back to the start values after the end |
repeat | 0 | Extra times to play (Infinity loops) |
onUpdate | none | Called every step with the eased progress |
onComplete | none | Called at the end (not when cancelled) |
Each call returns a FeelTween: cancel() stops where it is, finish() jumps to the end, done and finished (a promise that resolves on completion). A new tween on the same field takes over from the old one.
EASE names: linear, quadIn, quadOut, quadInOut, cubicIn, cubicOut, cubicInOut, sineInOut, backIn, backOut, elasticOut, bounceOut (the same names as the 2D layer's easings).
Hooks
| Hook | What it does |
|---|---|
useTweens() | A TweenRunner that belongs to one component: stepped with the scene, cancelled on unmount. Use it for an object's own idle motion (a lantern bobbing) |
useFeelClock() | The canvas clock, to read the current scale or the scaled elapsedTime in a useFrame |
Lower-level exports: TweenRunner, FeelClock, CameraShake, createFeel, getFeel (for tests and custom setups).
Gotchas
- One strong cue per moment, two at most. A hit is shake plus hit-stop; a death is slow motion plus flash. Everything on every event reads as noise.
- Keep slow motion short (400 ms or less) in play; longer only on a death or a win.
- Squash and stretch suit cartoon bodies and props; on a realistic body it looks like crumpling. Never on the level.
- Time effects are timed in real milliseconds and the shake decays in real time, so a hit-stop does not freeze a shake mid-jolt.
- There is one active feel state per app: a newly mounted
GameFeelreplaces the previous one, andshake()and the others act on it. FeelOverlayregisters its flash once for the whole game, not on the feel state, soflash()and the flashes in thejuicepresets work whichever ofFeelOverlayandGameFeelmounts first (in the usual layout the overlay mounts first: the canvas mounts its scene after the renderer starts).- Pair a moment with a sound (the audio mixer) and with the HUD's
Bumpor floating numbers for the number that changed.