A hit that lands needs a moment to be felt. Fighting games and action games stop the action for a few hundredths of a second when a blow connects (hit-stop, or freeze frames) and shake the camera a little. In Unity, both are usually added in an afternoon with Time.timeScale and a random offset on the camera, and both cause the bugs players notice first: a game that stays frozen after two quick hits, slow motion that ends early, a camera that jitters or drifts. This guide shows how to build the two effects so they do not break the rest of the game. The plain C# parts were run on .NET 8 and every script was compiled against the Unity 6 assemblies; the Unity editor itself was not run.
#The hit-stop everyone writes first
using System.Collections;
using UnityEngine;
public sealed class NaiveHitStop : MonoBehaviour
{
public void Hit(float seconds) => StartCoroutine(HitStopNaive(seconds));
IEnumerator HitStopNaive(float seconds)
{
float previous = Time.timeScale;
Time.timeScale = 0f;
yield return new WaitForSeconds(seconds); // scaled time: never ends at time scale 0
Time.timeScale = previous;
}
}
It has three problems, in the order you meet them.
- It never ends.
WaitForSecondscounts scaled time, and Unity's documentation says plainly that coroutines waiting on it are not resumed while the time scale is zero. The first hit freezes the game for good.WaitForSecondsRealtimefixes this one. - Overlapping hits. With the real-time wait, a second hit 50 ms after the first remembers the time scale it finds, which is 0, and restores 0 when it ends. We simulated exactly that: after two hits the time scale stayed at 0. The game is frozen again, just less often, which makes it harder to reproduce.
- No limit. A shotgun blast or a flamethrower can call
Hit()every frame. Even a correct freeze then stops the game for as long as the button is held.
#A hit-stop clock that runs on real time
The fix is to treat hit-stop as a small clock with its own state, advanced by real time, instead of a list of coroutines that each think they own the time scale. It remembers the game's own scale once, when a freeze starts from nothing; a freeze that arrives during another one extends it; and a chain of freezes has a maximum length, followed by a short pause in which new freezes are ignored:
using System;
public sealed class HitStopClock
{
public float MaxChain = 0.3f; // longest freeze in one chain of hits, real seconds
public float Cooldown = 0.12f; // after a capped chain, new freezes wait this long
float _left, _chain, _sinceCapped = float.MaxValue;
bool _capped;
public float BaseScale { get; private set; } = 1f;
public float Scale { get; private set; } = 1f;
public bool Active => _left > 0f;
public void Freeze(float seconds, float scale, float currentTimeScale)
{
if (!Active && _sinceCapped < Cooldown) return;
if (!Active)
{
BaseScale = currentTimeScale; // the game's own scale, remembered once
Scale = scale;
_chain = 0f;
}
else
{
Scale = Math.Min(Scale, scale);
}
_left = Math.Max(_left, Math.Min(seconds, MaxChain - _chain));
_capped = _chain + _left >= MaxChain - 1e-6f;
}
// Call once a frame with real (unscaled) time; returns the time scale to use now.
public float Step(float realDt, float currentTimeScale)
{
if (!Active)
{
if (_sinceCapped < float.MaxValue) _sinceCapped += realDt;
return currentTimeScale;
}
float used = Math.Min(realDt, _left);
_left -= used;
_chain += used;
if (Active) return Scale;
_sinceCapped = _capped ? 0f : float.MaxValue;
return BaseScale;
}
public float Release()
{
_left = 0f;
return BaseScale;
}
}
It has no Unity types in it, so we could run it on .NET at 60 frames a second:
- a freeze of 0.08 s while the game ran in slow motion at 0.5 lasted 5 frames and gave 0.5 back, not 1;
- two hits 50 ms apart made one freeze of about 0.15 s and then returned to normal speed;
- a hit on every frame for two seconds never froze the game for more than about 0.3 s at a time, and the game kept moving between the chains.
The component around it is short. It reads Time.unscaledDeltaTime, which keeps running while the time scale is zero, and puts the game's scale back if it is disabled in the middle of a freeze, for example when the scene unloads:
using UnityEngine;
public sealed class HitStop : MonoBehaviour
{
readonly HitStopClock _clock = new HitStopClock();
public void Freeze(float seconds, float scale = 0f)
{
_clock.Freeze(seconds, scale, Time.timeScale);
if (_clock.Active) Time.timeScale = _clock.Scale;
}
void Update()
{
float now = _clock.Step(Time.unscaledDeltaTime, Time.timeScale);
if (now != Time.timeScale) Time.timeScale = now;
}
void OnDisable()
{
if (_clock.Active) Time.timeScale = _clock.Release();
}
}
Keep the freezes short. Values between 0.03 and 0.12 seconds are common; a light hit gets the short end and a critical hit the long end. If your game has a pause menu that also sets the time scale, give the pause its own flag and let the hit-stop clock own only the freeze, or the two will undo each other.
#What else stops at time scale zero
Freezing Time.timeScale freezes everything that runs on scaled time, which is usually what you want, with a few exceptions to decide on purpose:
- Physics.
FixedUpdateis not called at time scale zero, so rigidbodies stop exactly where they were. That is the point of hit-stop. - Animation. Animators on the default update mode freeze too. A UI animator, such as a health bar that drains, can use the Unscaled Time update mode so it keeps moving.
- Particles. The spark at the point of impact looks better if it plays during the freeze; a particle system's main module has a "use unscaled time" switch for that.
- The camera shake. If the shake runs on scaled time, it stops for the length of the freeze, exactly when the hit should feel strongest. Run it on unscaled time.
#A camera shake that does not drift
The other half of the effect is the shake, and most of its bugs come from Random.insideUnitSphere and from writing to the camera's own position. Random offsets change completely every frame, so the shake looks like noise on a broken screen and gets worse at high frame rates. Writing into the camera's position fights with whatever moves the camera: a follow script, Cinemachine, a cutscene.
The trauma model, made popular by a GDC talk on camera shake, avoids both. Hits add "trauma" between 0 and 1, trauma falls linearly over time, and the shake strength is trauma squared. A small hit that adds 0.2 trauma gives a strength of 0.04 and barely moves the camera; a heavy one that brings it to 0.8 gives 0.64. Smooth noise instead of random numbers makes the motion look like a camera being knocked, not a glitch. Put the script on its own child object between your camera rig and the Camera, and it can simply own its local position:
using UnityEngine;
// On its own child object between the camera rig and the Camera.
public sealed class TraumaShake : MonoBehaviour
{
public float decay = 1.4f; // trauma lost per second
public float maxOffset = 0.4f; // units at full trauma
public float maxRoll = 3f; // degrees at full trauma
public float frequency = 18f; // bumps per second
[Range(0f, 1f)] public float intensity = 1f; // the player's "screen shake" setting
float _trauma, _time;
public void AddTrauma(float amount) => _trauma = Mathf.Clamp01(_trauma + amount);
void LateUpdate()
{
float dt = Time.unscaledDeltaTime; // keeps shaking during hit-stop
_time += dt * frequency;
float s = _trauma * _trauma * intensity;
transform.localPosition = new Vector3(Noise(0f), Noise(10f), 0f) * (maxOffset * s);
transform.localRotation = Quaternion.Euler(0f, 0f, Noise(20f) * maxRoll * s);
_trauma = Mathf.Max(0f, _trauma - decay * dt);
}
float Noise(float channel) => Mathf.PerlinNoise(_time, channel) * 2f - 1f;
}
Because the follow script moves the parent and the shake moves only the child, neither overwrites the other, and when the trauma reaches zero the child is back at exactly zero. Small rotation does more than large movement: three degrees of roll reads as a strong hit, while half a unit of movement at full trauma is already a lot in a 2D game.
Keep the intensity field. Screen shake is one of the most common things players ask to turn down, for comfort or motion sickness, and a slider in the options menu that feeds this value costs nothing.
#Putting a hit together
On a hit, call both from one place and scale them with the strength of the blow: trauma grows with the strength, the freeze grows more slowly, for example with its square root, so a critical hit feels heavier without stopping the game twice as long. Add a short flash on the target and a little knockback, and the hit reads clearly even in a busy scene. For browser games the same ideas apply with other tools; see our guide to making a browser game feel good.
#A package that does this
We built these pieces into Tresko, a game feel package for Unity: trauma camera shake on unscaled time, hit-stop that gives the game's time scale back with overlapping freezes and a capped chain, squash and stretch on a spring, hit flash without material copies, knockback that travels an exact distance at any frame rate, punch, 18 easing curves and one Impact() call that fires them all. The maths is a plain C# core checked by 23 unit tests on .NET 8; the package was compiled against the assemblies of Unity 6000.5.4, but the Unity editor was not run. It is listed as Coming soon.
#Frequently asked questions
Why does my game stay frozen after hit-stop?
Either the coroutine waits with WaitForSeconds, which does not resume while the time scale is zero, or a second hit remembered the frozen scale and restored it. Use real time and remember the game's scale only when a freeze starts from nothing.
How long should hit-stop last?
A few frames: between about 0.03 and 0.12 seconds, longer for heavier hits. Cap the total for chains of hits, so continuous damage cannot stop the game.
Why does my camera shake stop during hit-stop?
It runs on scaled time. Use Time.unscaledDeltaTime for the shake.
Does this work with Cinemachine?
Put the shake on its own object that Cinemachine does not drive, or use Cinemachine's own impulse system for the shake and keep the hit-stop clock for the freeze. The point is that only one thing writes each transform.
#Sources
- Unity Scripting Reference: Time.timeScale
- Unity Scripting Reference: WaitForSeconds
- Unity Scripting Reference: Time.unscaledDeltaTime
- Unity Scripting Reference: AnimatorUpdateMode.UnscaledTime
- Unity Scripting Reference: ParticleSystem.MainModule.useUnscaledTime
- Unity Scripting Reference: Mathf.PerlinNoise
Comments
Comments
Be the first to leave a comment.
Leave a comment