# 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