Skip to main content

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.

PropTypeDefaultWhat it does
shakeShakeOptionssee belowTune the camera shake
timeScalenumber1A 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​

FunctionWhat 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');
}
NameShakeHit-stopFlashSlow motionObjectHaptic
bump0.2light
land0.12squashlight
pickupwhite, 90 ms, 0.12vanishlight
hit0.4560 mswhite, 90 ms, 0.35squashmedium
explosion0.980 msamber, 180 ms, 0.50.45 for 260 msheavy
death0.6red, 320 ms, 0.50.25 for 900 mssquashheavy

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 });
}
FunctionWhat 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:

OptionDefaultWhat it does
duration0.3Seconds
ease'cubicOut'An EASE name or your own function
delay0Seconds before it starts
yoyofalseGo back to the start values after the end
repeat0Extra times to play (Infinity loops)
onUpdatenoneCalled every step with the eased progress
onCompletenoneCalled 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​

HookWhat 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 GameFeel replaces the previous one, and shake() and the others act on it.
  • FeelOverlay registers its flash once for the whole game, not on the feel state, so flash() and the flashes in the juice presets work whichever of FeelOverlay and GameFeel mounts 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 Bump or floating numbers for the number that changed.