Skip to content
PN Scripts

Games

Game feel in Unity: hit-stop and screen shake without breaking time scale

  • PN Scripts
  • 9 min read

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. WaitForSeconds counts 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. WaitForSecondsRealtime fixes 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. FixedUpdate is 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

Keep reading

Keep reading

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile, API and game development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here