# 4. Hit-Stop, Knockback and Feedback
Part of [[Combat Feel and Hit Feedback]]. Prev: [[3. Hitboxes and Hurtboxes]]. Next: [[5. I-Frames Dodge and Guard]].
The numbers already changed in `ApplyDamage`. This part is everything the player *notices*: a few frames of frozen contact, a camera kick, a spark, a meaty tick, and a body that actually moves.
## 4.1 Hit-stop: two clocks
**Hit-stop** (or hit-freeze) is a very short pause at the moment of contact so the brain can register the hit. Street-level values are often 30-80 ms at a `timeScale` around 0.05-0.15. Longer than that reads as a bug; shorter than one frame reads as nothing.
You have two honest implementations:
| Approach | What it freezes | Use when |
|---|---|---|
| **`Time.timeScale`** | The whole game (animations, physics, scaled waits) | A dramatic, full-screen freeze. Boss finishers. One hit at a time. |
| **Local feel clock** | Only the attacker and victim Animators (and any motor that opts in) | Many simultaneous hits, UI and other actors must keep moving, or you are already using `timeScale` for pause. |
Official `Time.timeScale` behaviour (6000.5) matters:
- The new scale takes effect **on the following frames**.
- At `0`, `FixedUpdate` is not called, and coroutines suspended with `WaitForSeconds` do not resume. `WaitForSecondsRealtime` and `Time.unscaledDeltaTime` still run.
- If you change `timeScale` but *not* `Time.fixedDeltaTime`, physics stays consistent in *game* time, not wall-clock time. The official docs show the opposite choice: `Time.fixedDeltaTime = savedFixed * Time.timeScale` to keep a steady *real-time* physics rate. For a 60 ms combat freeze you usually **do** want physics to pause with the game, so leave `fixedDeltaTime` alone - or set the scale briefly and restore both values in `finally`. Negative scales are ignored. Never leave `timeScale` at 0 if a hit-stop is interrupted by a scene load.
`Awaitable.WaitForSecondsAsync` is a **scaled** wait. Do not use it to time the freeze. Count `Time.unscaledDeltaTime` instead.
```csharp
using System.Threading;
using UnityEngine;
/// <summary>
/// Global hit-stop via Time.timeScale. Restore in finally so a cancelled freeze cannot stall the game.
/// NOT compiled in Unity.
/// </summary>
public sealed class HitStopService : MonoBehaviour
{
int _token;
float _savedScale = 1f;
float _savedFixed;
bool _held;
public async Awaitable Play(float scale, float duration, CancellationToken cancel = default)
{
if (duration <= 0f || scale >= 1f) return;
int mine = ++_token;
if (!_held)
{
_savedScale = Time.timeScale;
_savedFixed = Time.fixedDeltaTime;
_held = true;
}
Time.timeScale = Mathf.Max(0f, scale);
// Leave fixedDeltaTime alone so physics sleeps with the game during the freeze.
try
{
float t = 0f;
while (t < duration)
{
if (cancel.IsCancellationRequested) break;
t += Time.unscaledDeltaTime;
await Awaitable.NextFrameAsync();
if (mine != _token) return; // a heavier hit-stop took over
}
}
finally
{
if (mine == _token)
{
Time.timeScale = _savedScale <= 0f ? 1f : _savedScale;
Time.fixedDeltaTime = _savedFixed > 0f ? _savedFixed : Time.fixedDeltaTime;
_held = false;
}
}
}
}
```
Hold a single service in the scene (or a lightweight service locator - [[Architecture and Code Patterns]]). If two hits land in the same frame, the later `Play` bumps `_token` and the first restore is skipped; the second restore still puts the clock back.
**Local alternative** when you must not touch the global clock:
```csharp
public static void SetCombatSpeed(Animator animator, float speed)
{
if (animator != null) animator.speed = speed;
}
```
`Animator.speed` is a multiplier on the state's own speed. If `Animator.updateMode` is `UnscaledTime`, a `timeScale` freeze will *not* stop that Animator - which is either a bug or a deliberate "UI character keeps breathing" choice. Combat actors should use the default **Normal** update mode so they freeze with the world. Drive the local clock back to `1` in the same `finally` style.
**Audio:** `AudioSource` playback can follow `timeScale` (pitch drops) depending on the source and mixer. For a 60 ms freeze this is often inaudible. For longer slow-mo, play combat one-shots through a mixer group that you have tested, or start the impact sound *after* the freeze. See [[Audio Mixers and Character Audio]].
## 4.2 Camera: Impulse, not a scripted offset
Do not write `camera.transform.position += shake`. Add a `CinemachineImpulseSource` on the attacker (or a combat-feedback object) and a `CinemachineImpulseListener` on the gameplay `CinemachineCamera`. This is the whole point of [[4.Impulse Noise and Extensions]].
```csharp
using UnityEngine;
using Unity.Cinemachine;
public sealed class HitImpulse : MonoBehaviour
{
[SerializeField] CinemachineImpulseSource source;
public void Fire(AttackDefinition attack, Vector3 worldVelocity)
{
if (source == null) return;
if (worldVelocity.sqrMagnitude > 0.0001f)
source.GenerateImpulseWithVelocity(worldVelocity.normalized * attack.impulseForce);
else
source.GenerateImpulseWithForce(attack.impulseForce);
}
}
```
`GenerateImpulse()` uses a default velocity at the source position. Directional `GenerateImpulseWithVelocity` reads better for a heavy sideways blow. Keep Perlin idle noise tiny; let Impulse carry the hit.
**VR:** do not Impulse the tracked HMD cameras. A visor shake is nausea, not juice. If you teach this on Quest, skip the Impulse or restrict it to a non-HMD spectator camera ([[7.Pitfalls]] in [[Cinemachine 3]], [[Meta Quest 2]]).
## 4.3 VFX and audio at the contact point
Prefer a pooled impact and a named VFX event over `Instantiate` on every hit ([[VFX Graph]], [[3. Worked Effect - Impact Sparks with One Graph]], [[2. Pooled AudioSource Service]]).
```csharp
using UnityEngine;
using UnityEngine.VFX;
public sealed class HitVfxAudio : MonoBehaviour
{
[SerializeField] VisualEffect impactEffect;
[SerializeField] AudioSource pooledSource; // swap for your pool service when you have one
public void Play(in DamageInfo info)
{
if (impactEffect != null)
{
impactEffect.transform.position = info.contactPoint;
impactEffect.transform.rotation = Quaternion.LookRotation(
info.contactNormal.sqrMagnitude > 0.0001f ? info.contactNormal : Vector3.up);
if (info.attack != null && !string.IsNullOrEmpty(info.attack.vfxEventName))
impactEffect.SendEvent(info.attack.vfxEventName);
else
impactEffect.Play();
}
var clip = info.attack != null ? info.attack.impactClip : null;
if (pooledSource != null && clip != null)
{
pooledSource.transform.position = info.contactPoint;
pooledSource.PlayOneShot(clip);
}
}
}
```
`VisualEffect.SendEvent(string)` (and the `int` name-id overloads) is the official component API; `Play()` sends the default OnPlay event. Wire the event name in the graph to a spawn context, not a second graph per hit if you can avoid it.
A low-health **Volume** pulse ([[3. Worked Example - Grade Damage and a Cave]]) can layer on top. It is mood, not the hit. Do not substitute a full-screen flash for a contact spark.
## 4.4 Knockback is a motor problem
`DamageInfo.knockback` is a world-space vector. What consumes it depends on the victim:
- **`Rigidbody` character** ([[4. Rigidbody Character Implementation]]): add to `linearVelocity` (Unity 6 name; do not use the old `velocity` property). Prefer a method on the motor so it can ignore knockback during a scripted hold.
- **`CharacterController`** ([[3. CharacterController Implementation]]): the controller has no instant impulse API. Accumulate an external velocity the `Move` loop adds, then decay it.
- **NavMesh enemies** ([[NavMesh in Unity 6 (AI Navigation 2.x)]]): either warp a short knock, or temporarily stop the agent and slide the transform, then `Warp` back onto the mesh. Do not fight the agent every frame.
```csharp
using UnityEngine;
public sealed class KnockbackReceiver : MonoBehaviour
{
[SerializeField] Rigidbody body; // optional
[SerializeField] CharacterController character; // optional
[SerializeField] float ccDecay = 8f;
Vector3 _ccVelocity;
public void Apply(Vector3 worldImpulse)
{
if (body != null)
{
body.linearVelocity += worldImpulse;
return;
}
_ccVelocity += worldImpulse;
}
void Update()
{
if (character == null || _ccVelocity.sqrMagnitude < 0.0001f) return;
character.Move(_ccVelocity * Time.deltaTime);
_ccVelocity = Vector3.MoveTowards(_ccVelocity, Vector3.zero, ccDecay * Time.deltaTime);
}
}
```
During a `timeScale` freeze, this `Update` barely moves, which is what you want: the body *starts* its flight when time resumes.
## 4.5 The dispatcher
One component implements `IDamageable` and fans the event out. Keep it boring.
```csharp
using UnityEngine;
public sealed class CombatVictim : MonoBehaviour, IDamageable
{
[SerializeField] TeamId team = TeamId.Enemy;
[SerializeField] int maxHealth = 50;
[SerializeField] Animator animator;
[SerializeField] KnockbackReceiver knockback;
[SerializeField] HitStopService hitStop;
[SerializeField] HitImpulse impulse;
[SerializeField] HitVfxAudio feedback;
[SerializeField] Hurtbox hurtbox;
static readonly int HitHash = Animator.StringToHash("Hit");
static readonly int DieHash = Animator.StringToHash("Die");
int _health;
public TeamId Team => team;
public bool IsAlive => _health > 0;
public bool IsInvulnerable => hurtbox != null && hurtbox.Collider != null && !hurtbox.Collider.enabled;
void Awake() => _health = maxHealth;
public void ApplyDamage(in DamageInfo info)
{
if (!IsAlive || IsInvulnerable) return;
_health = Mathf.Max(0, _health - info.amount);
knockback?.Apply(info.knockback);
feedback?.Play(info);
impulse?.Fire(info.attack, info.knockback);
if (hitStop != null && info.hitStopDuration > 0f)
_ = hitStop.Play(info.hitStopScale, info.hitStopDuration, destroyCancellationToken);
if (animator != null)
{
if (_health <= 0) animator.SetTrigger(DieHash);
else animator.SetTrigger(HitHash);
}
}
}
```
The `_ = hitStop.Play(...)` fire-and-forget is acceptable *because* `Play` restores time in `finally` and is cancelled by `destroyCancellationToken` ([[3. Cancellation Tokens]], [[5. Async Pitfalls]]). Do not fire-and-forget a method that leaves `timeScale` at 0.05 if it is cancelled mid-wait without a `finally`.
Hook a "killed" event here later for [[Quest Systems]] kill objectives - an Observer on death, not a query in the quest log.
## Sources
- `Time.timeScale` (6000.5): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Time-timeScale.html
- `CinemachineImpulseSource` (3.1): https://docs.unity3d.com/Packages/
[email protected]/api/Unity.Cinemachine.CinemachineImpulseSource.html
- `VisualEffect.SendEvent`: https://docs.unity3d.com/Documentation/ScriptReference/VFX.VisualEffect.SendEvent.html
- `Rigidbody.linearVelocity` (6000.5): https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Rigidbody-linearVelocity.html
- `MonoBehaviour.destroyCancellationToken`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/MonoBehaviour-destroyCancellationToken.html