# Game feel

> Camera shake, hit-stop, slow motion, a screen flash, squash and stretch, pops and tweens on any three object, and the juice presets that combine them, from GameFeel and FeelOverlay.

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](https://docs.moonspell.app/engine/hud-and-ui.md)).

## Smallest working example

Mount `GameFeel` once inside the canvas and `FeelOverlay` once over it, then call the tools from anywhere:

```tsx
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:

```ts
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 }`.

```ts
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 `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](https://docs.moonspell.app/engine/audio.md)) and with the HUD's `Bump` or floating numbers for the number that changed.
