Screen Shake

Camera shake on impact, explosion, or emphasis. One of the most powerful feedback tools - and one of the most overused.

Practice - what you do

The Feel

Screen shake says: something important happened. It's emphasis. Exclamation point. Used well, it makes impacts feel weighty. Used poorly, it makes videogames feel noisy.

Exposed Variables

Variable Type Default What it does
intensity float 5.0 Maximum pixel offset from center
duration float 0.2 How long shake lasts (seconds)
decayType enum Linear How intensity decreases (Linear, Exponential, None)
frequency float 30.0 How many shake "hits" per second
rotationIntensity float 0.0 Camera rotation shake (degrees)
shakePattern enum Random Pattern of offset (Random, Perlin, Directional)
direction Vector2 null For Directional pattern: bias direction

Tuning Guide

Subtle impact (footsteps, small hits):

  • intensity: 1-3
  • duration: 0.05-0.1
  • decayType: Exponential
  • rotationIntensity: 0

Medium impact (melee hits, jumps landing):

  • intensity: 5-10
  • duration: 0.1-0.2
  • decayType: Linear
  • rotationIntensity: 0-1

Heavy impact (explosions, boss hits):

  • intensity: 15-25
  • duration: 0.2-0.4
  • decayType: Exponential
  • rotationIntensity: 1-3

Directional impact (knockback, recoil):

  • shakePattern: Directional
  • direction: Opposite of force direction
  • Shorter duration, higher initial intensity

Pseudocode

Pseudocode
On Shake Trigger:
  - Set shake timer to duration
  - Store base intensity

Every Frame (while shaking):
  - Calculate current intensity (based on decayType)
  - Generate offset based on shakePattern
  - Apply offset to camera position
  - If rotationIntensity > 0: apply rotation offset
  - Decrement timer

On Shake End:
  - Smoothly return camera to base position

Unity Setup

  1. Add to Camera:
    • Add ScreenShake.cs to your Main Camera
    • Or create an empty "CameraShake" child of Camera
    • The script uses singleton pattern - easy access from anywhere
  2. Call from Game Events:
    • ScreenShake.Instance.Shake(intensity, duration);
    • Or use presets: ScreenShake.Instance.ShakeLight();
  3. IMPORTANT - Accessibility:
    • Connect the globalIntensityMultiplier to your settings menu
    • Set to 0 to disable shake entirely
    • Expose as a slider (0-100%)
    • This is required accessibility, not optional polish
  4. Camera Follow Note:
    • If using Cinemachine, use their impulse system instead
    • If using custom camera follow, apply shake AFTER follow logic

The Script

ScreenShake.cs
// ============================================================
// ScreenShake.cs
// Camera shake with multiple patterns, decay types, and
// accessibility support. Singleton for easy access.
// Unity 6.x
// ============================================================

using UnityEngine;

public enum ShakeDecayType
{
    None,        // Constant intensity throughout
    Linear,      // Steady decrease
    Exponential  // Fast decrease that slows down
}

public enum ShakePattern
{
    Random,      // Random direction each frame
    Perlin,      // Smooth noise (less jittery)
    Directional  // Biased toward a direction
}

public class ScreenShake : MonoBehaviour
{
    // ========================================================
    // SINGLETON
    // ========================================================

    public static ScreenShake Instance { get; private set; }

    // ========================================================
    // ACCESSIBILITY - CRITICAL!
    // ========================================================

    [Header("Accessibility Settings")]
    [Tooltip("Global shake intensity multiplier (0 = disabled). Connect to settings menu!")]
    [SerializeField] [Range(0f, 1f)] private float globalIntensityMultiplier = 1f;

    // ========================================================
    // PRESET INTENSITIES
    // ========================================================

    [Header("Preset Values")]
    [Tooltip("Light shake: intensity")]
    [SerializeField] private float lightIntensity = 0.1f;
    [Tooltip("Light shake: duration")]
    [SerializeField] private float lightDuration = 0.05f;

    [Tooltip("Medium shake: intensity")]
    [SerializeField] private float mediumIntensity = 0.3f;
    [Tooltip("Medium shake: duration")]
    [SerializeField] private float mediumDuration = 0.15f;

    [Tooltip("Heavy shake: intensity")]
    [SerializeField] private float heavyIntensity = 0.6f;
    [Tooltip("Heavy shake: duration")]
    [SerializeField] private float heavyDuration = 0.25f;

    [Header("Default Settings")]
    [Tooltip("Default decay type for all shakes")]
    [SerializeField] private ShakeDecayType defaultDecayType = ShakeDecayType.Exponential;

    [Tooltip("Default shake pattern")]
    [SerializeField] private ShakePattern defaultPattern = ShakePattern.Perlin;

    [Header("Rotation Shake")]
    [Tooltip("Enable rotation shake (can cause more motion sickness)")]
    [SerializeField] private bool enableRotationShake = false;

    [Tooltip("Maximum rotation in degrees")]
    [SerializeField] [Range(0f, 5f)] private float maxRotation = 1f;

    [Header("Smoothing")]
    [Tooltip("How fast shake returns to zero when done")]
    [SerializeField] [Range(1f, 30f)] private float recoverySpeed = 15f;

    [Header("Debug")]
    [SerializeField] private bool debugMode = false;

    // ========================================================
    // INTERNAL STATE
    // ========================================================

    private Vector3 originalPosition;
    private Quaternion originalRotation;

    // Current shake state
    private float currentIntensity = 0f;
    private float currentDuration = 0f;
    private float shakeTimer = 0f;
    private ShakeDecayType currentDecayType;
    private ShakePattern currentPattern;
    private Vector2 shakeDirection = Vector2.zero;

    // Perlin noise seeds (for smooth shake)
    private float perlinSeedX;
    private float perlinSeedY;

    // Current offset (for smooth recovery)
    private Vector3 currentOffset = Vector3.zero;
    private float currentRotationOffset = 0f;

    // ========================================================
    // PUBLIC PROPERTIES
    // ========================================================

    /// <summary>
    /// True while shake is active
    /// </summary>
    public bool IsShaking => shakeTimer > 0f;

    /// <summary>
    /// Get/set the global intensity (for settings menu)
    /// </summary>
    public float GlobalIntensity
    {
        get => globalIntensityMultiplier;
        set => globalIntensityMultiplier = Mathf.Clamp01(value);
    }

    // ========================================================
    // UNITY LIFECYCLE
    // ========================================================

    void Awake()
    {
        // Singleton setup
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }
        Instance = this;

        // Store original transform
        originalPosition = transform.localPosition;
        originalRotation = transform.localRotation;

        // Random perlin seeds
        perlinSeedX = Random.Range(0f, 100f);
        perlinSeedY = Random.Range(0f, 100f);
    }

    void LateUpdate()
    {
        // LateUpdate so shake happens AFTER camera follow

        if (shakeTimer > 0f)
        {
            ProcessShake();
        }
        else
        {
            // Smooth recovery to original position
            RecoverFromShake();
        }
    }

    // ========================================================
    // SHAKE PROCESSING
    // ========================================================

    /// <summary>
    /// Process active shake
    /// </summary>
    private void ProcessShake()
    {
        shakeTimer -= Time.deltaTime;

        // Calculate current intensity based on decay type
        float progress = 1f - (shakeTimer / currentDuration);
        float intensityMultiplier = CalculateDecay(progress);
        float finalIntensity = currentIntensity * intensityMultiplier * globalIntensityMultiplier;

        // Skip if accessibility disabled shake
        if (globalIntensityMultiplier <= 0f)
        {
            return;
        }

        // Generate offset based on pattern
        Vector2 offset = CalculateOffset(finalIntensity);
        currentOffset = new Vector3(offset.x, offset.y, 0f);

        // Apply position offset
        transform.localPosition = originalPosition + currentOffset;

        // Apply rotation if enabled
        if (enableRotationShake && maxRotation > 0f)
        {
            currentRotationOffset = CalculateRotation(finalIntensity);
            transform.localRotation = originalRotation * Quaternion.Euler(0f, 0f, currentRotationOffset);
        }
    }

    /// <summary>
    /// Calculate decay multiplier based on type
    /// </summary>
    private float CalculateDecay(float progress)
    {
        switch (currentDecayType)
        {
            case ShakeDecayType.None:
                return 1f;

            case ShakeDecayType.Linear:
                return 1f - progress;

            case ShakeDecayType.Exponential:
                // Fast falloff that slows down
                return Mathf.Pow(1f - progress, 2f);

            default:
                return 1f - progress;
        }
    }

    /// <summary>
    /// Calculate position offset based on pattern
    /// </summary>
    private Vector2 CalculateOffset(float intensity)
    {
        switch (currentPattern)
        {
            case ShakePattern.Random:
                return new Vector2(
                    Random.Range(-1f, 1f) * intensity,
                    Random.Range(-1f, 1f) * intensity
                );

            case ShakePattern.Perlin:
                // Smooth noise that changes over time
                float time = Time.time * 20f; // Speed of noise change
                float x = (Mathf.PerlinNoise(perlinSeedX + time, 0f) - 0.5f) * 2f * intensity;
                float y = (Mathf.PerlinNoise(0f, perlinSeedY + time) - 0.5f) * 2f * intensity;
                return new Vector2(x, y);

            case ShakePattern.Directional:
                // Bias toward direction, with some perpendicular noise
                float parallel = Random.Range(0.5f, 1f) * intensity;
                float perpendicular = Random.Range(-0.3f, 0.3f) * intensity;
                Vector2 perp = new Vector2(-shakeDirection.y, shakeDirection.x);
                return (shakeDirection * parallel) + (perp * perpendicular);

            default:
                return Vector2.zero;
        }
    }

    /// <summary>
    /// Calculate rotation offset
    /// </summary>
    private float CalculateRotation(float intensity)
    {
        float normalizedIntensity = intensity / currentIntensity;
        return Random.Range(-1f, 1f) * maxRotation * normalizedIntensity;
    }

    /// <summary>
    /// Smooth return to original position
    /// </summary>
    private void RecoverFromShake()
    {
        // Lerp back to original
        currentOffset = Vector3.Lerp(currentOffset, Vector3.zero, recoverySpeed * Time.deltaTime);
        transform.localPosition = originalPosition + currentOffset;

        if (enableRotationShake)
        {
            currentRotationOffset = Mathf.Lerp(currentRotationOffset, 0f, recoverySpeed * Time.deltaTime);
            transform.localRotation = originalRotation * Quaternion.Euler(0f, 0f, currentRotationOffset);
        }
    }

    // ========================================================
    // PUBLIC METHODS - Call these to trigger shake
    // ========================================================

    /// <summary>
    /// Trigger shake with full control
    /// </summary>
    public void Shake(float intensity, float duration,
                      ShakeDecayType decayType = ShakeDecayType.Exponential,
                      ShakePattern pattern = ShakePattern.Perlin,
                      Vector2? direction = null)
    {
        // Don't override stronger shake with weaker one
        if (shakeTimer > 0f && currentIntensity > intensity)
        {
            return;
        }

        currentIntensity = intensity;
        currentDuration = duration;
        shakeTimer = duration;
        currentDecayType = decayType;
        currentPattern = pattern;

        if (direction.HasValue)
        {
            shakeDirection = direction.Value.normalized;
        }
        else
        {
            shakeDirection = Vector2.right;
        }

        // New perlin seeds for variety
        perlinSeedX = Random.Range(0f, 100f);
        perlinSeedY = Random.Range(0f, 100f);

        if (debugMode)
        {
            Debug.Log($"Shake triggered: intensity={intensity}, duration={duration}, pattern={pattern}");
        }
    }

    /// <summary>
    /// Simple shake with just intensity and duration (uses defaults)
    /// </summary>
    public void Shake(float intensity, float duration)
    {
        Shake(intensity, duration, defaultDecayType, defaultPattern);
    }

    /// <summary>
    /// Light shake preset
    /// </summary>
    public void ShakeLight()
    {
        Shake(lightIntensity, lightDuration);
    }

    /// <summary>
    /// Medium shake preset
    /// </summary>
    public void ShakeMedium()
    {
        Shake(mediumIntensity, mediumDuration);
    }

    /// <summary>
    /// Heavy shake preset
    /// </summary>
    public void ShakeHeavy()
    {
        Shake(heavyIntensity, heavyDuration);
    }

    /// <summary>
    /// Directional shake (e.g., recoil, knockback)
    /// </summary>
    public void ShakeDirectional(float intensity, float duration, Vector2 direction)
    {
        Shake(intensity, duration, ShakeDecayType.Exponential, ShakePattern.Directional, direction);
    }

    /// <summary>
    /// Immediately stop shake
    /// </summary>
    public void StopShake()
    {
        shakeTimer = 0f;
    }
}

// ============================================================
// USAGE EXAMPLES:
//
// 1. Basic shake on hit:
//    void OnEnemyHit()
//    {
//        ScreenShake.Instance.ShakeMedium();
//    }
//
// 2. Scaled shake based on damage:
//    void TakeDamage(int damage)
//    {
//        float intensity = Mathf.Clamp(damage * 0.1f, 0.1f, 1f);
//        ScreenShake.Instance.Shake(intensity, 0.15f);
//    }
//
// 3. Directional shake for recoil:
//    void FireWeapon(Vector2 fireDirection)
//    {
//        // Shake opposite to fire direction
//        ScreenShake.Instance.ShakeDirectional(0.2f, 0.1f, -fireDirection);
//    }
//
// 4. Combine with hitstop:
//    void OnBigHit()
//    {
//        HitstopManager.Instance.TriggerHeavyHit();
//        ScreenShake.Instance.ShakeHeavy();
//    }
//
// 5. In your settings menu (REQUIRED!):
//    void OnShakeSliderChanged(float value)
//    {
//        ScreenShake.Instance.GlobalIntensity = value;
//        PlayerPrefs.SetFloat("ScreenShakeIntensity", value);
//    }
//
// 6. For 3D: Same script works, just uses XY offset by default.
//    For full 3D shake, add Z offset in CalculateOffset.
// ============================================================

Common Issues

"The shake is nauseating"
Reduce intensity and duration. Add decayType: Exponential so it calms quickly.

"Everything shakes and nothing feels special"
Shake less often. Reserve for meaningful moments. Create intensity hierarchy.

"The shake feels jittery/wrong"
Try shakePattern: Perlin for smoother noise. Or reduce frequency.

"Players with motion sensitivity can't play"
Always include a shake disable option. This is accessibility, not preference.

Shake Hierarchy

Not all shakes should be equal. Create a hierarchy:

Event Type Intensity Duration
Ambient (footsteps) 1-2 0.03
Minor impact 3-5 0.1
Standard impact 8-12 0.15
Major impact 15-20 0.2
Boss/explosion 25+ 0.3+

If everything is at "standard impact" level, nothing stands out.

When NOT to Shake

  • Every jump landing (unless you want that)
  • Background events player didn't cause
  • Menu interactions
  • Constant effects (rain, wind) - use subtle camera drift instead
  • If the player has shake disabled

Combine With

  • Hitstop - freeze THEN shake for maximum impact
  • Squash Stretch - visual deformation matches shake
  • Landing Impact - shake as part of landing feedback
  • Sound - shake without audio feels hollow

Accessibility Note

Screen shake can cause motion sickness, nausea, and disorientation for many players.

Always provide:

  • Shake intensity slider (0-100%)
  • Or disable option
  • Ideally both

This isn't optional. This is basic accessibility.


Related

Glossary Terms

  • Juice - shake as juice technique
  • Feedback - shake as feedback mechanism
  • Game Feel - the experience shake contributes to