12.4 UX, Feedback & 'Juice'

Phase 12 · UI / UX Programming · Study time: 15–25 h

The polish that makes UI satisfying — animations, sound, haptics and responsive feedback that make every tap feel alive.

Chapter 6.2 mentioned game feel in passing: coyote time and jump buffering change how a jump feels, even though the underlying math barely changed. This chapter widens that idea. Movement timing is only one slice of feel -- swinging a sword, opening a menu, picking up a coin, taking a hit -- every one of those needs its own layer of feedback, or it reads as dead and mechanical even when the code behind it is completely correct.

The informal industry word for this layer is juice (you will also see "feedback design" or "UX polish" in more formal writing). This chapter builds the actual pieces: screen flashes, screen shake done properly, sound, controller rumble, animated pops driven by easing curves, and the frame-by-frame responsiveness rules that make input feel instant. It also covers where juice becomes a problem -- for accessibility and for competitive clarity -- and how to dial it back on purpose.

1. What "game feel" and "juice" mean, and why they matter

Juice is every piece of feedback wrapped around an action that is not, strictly speaking, required to make the action work. If a player presses attack and an enemy's health number goes down, the game is functioning correctly. Nothing about that requires a flash, a sound, a shake, or a rumble. And yet a version with none of those feels lifeless, while a version with all of them feels satisfying, even though the underlying rule -- health -= damage -- is byte-for-byte identical in both.

CORE ACTION: player presses "attack", enemy.health -= 10 bare mechanic (no juice): [ button press ] --> [ health -= 10 ] ...and nothing else happens on screen juiced version (exact same math underneath): [ button press ] --> [ health -= 10 ] | | v v windup pose hit flash on enemy "swoosh" sound small screen shake controller rumble "-10" number pops up + particles

Here is that same idea as code, previewing where each line gets built in this chapter:

// no feedback -- functionally correct, feels dead
void OnAttackButton()
{
    enemy.health -= attackDamage;
}

// juiced -- the exact same rule, wrapped in feedback
void OnAttackButton()
{
    enemy.health -= attackDamage;

    enemy.FlashHit();                                            // section 2
    audioSource.PlayOneShot(hitClip);                             // section 3
    Gamepad.current?.SetMotorSpeeds(0.3f, 0.3f);                  // section 4
    cameraShake.AddTrauma(0.2f);                                  // section 7
    popupSpawner.Spawn(attackDamage, enemy.transform.position);   // section 2
}

Why does this matter so much? Because the player never sees your data. They never see health as a number sitting in memory -- they only see what the game shows and plays back to them. Feedback is the interface between your simulation and the player's understanding of it. A rule with no feedback might as well not exist from the player's point of view, and a rule with well-tuned feedback feels like it has weight and consequence, which is a huge part of why some games feel "good to touch" and others do not, even when their mechanics are similar on paper.

Tip A useful test while building any action: mute the game and turn off every visual effect except the bare mechanic. If you genuinely cannot tell that anything happened, that action is missing feedback, not "waiting for polish later." Feedback is not decoration added at the end -- treat it as part of the action's correctness.

2. Visual feedback: hit flash, particles, and number popups

Visual feedback is the channel players notice first because their eyes are already on the screen. Four visual techniques cover most situations: a brief color flash on the thing that got hit, screen shake (big enough topic to get its own section, 7), particle bursts, and floating number popups.

Hit flash

A hit flash snaps a sprite or model to a bright color (usually white) for a few frames, then fades back to its normal color. It is one of the cheapest, highest-value effects in this entire chapter -- a single coroutine, and it makes every hit instantly readable.

using UnityEngine;
using System.Collections;

public class HitFlash : MonoBehaviour
{
    public Color flashColor = Color.white;
    public float flashDuration = 0.1f;

    SpriteRenderer sr;
    Color baseColor;

    void Awake()
    {
        sr = GetComponent<SpriteRenderer>();
        baseColor = sr.color;
    }

    public void Flash()
    {
        StopAllCoroutines();
        StartCoroutine(FlashRoutine());
    }

    IEnumerator FlashRoutine()
    {
        float t = 0f;
        while (t < flashDuration)
        {
            t += Time.deltaTime;
            sr.color = Color.Lerp(flashColor, baseColor, t / flashDuration);
            yield return null;
        }
        sr.color = baseColor;
    }
}

Worked trace at 50 FPS (Time.deltaTime = 0.02s, so flashDuration = 0.1s is exactly 5 frames):

frame t (s) t / duration Color.Lerp(flashColor, baseColor, t/duration) 1 0.02 0.2 80% flash color, 20% base color 2 0.04 0.4 60% flash color, 40% base color 3 0.06 0.6 40% flash color, 60% base color 4 0.08 0.8 20% flash color, 80% base color 5 0.10 1.0 100% base color (flash finished)

Color.Lerp(a, b, t) moves from a toward b as t grows -- so at t = 0.2 the result is mostly still flashColor (the flash just started), and at t = 1.0 it has fully arrived at baseColor. The sprite pops instantly to white the frame Flash() is called (because it starts the loop with a fresh coroutine), then eases back over the next five frames.

Number popups

A number popup is small floating text ("-10", "+250 gold") that spawns at the hit point and drifts upward before disappearing. A basic version, before section 5 gives it a nicer motion curve:

public class NumberPopup : MonoBehaviour
{
    public TMP_Text label;
    public float riseSpeed = 1.5f;
    public float lifetime = 0.6f;

    float age;

    public void Init(int amount)
    {
        label.text = amount.ToString();
    }

    void Update()
    {
        age += Time.deltaTime;
        transform.position += Vector3.up * riseSpeed * Time.deltaTime;

        if (age >= lifetime)
            Destroy(gameObject);
    }
}

This moves at a constant speed and just vanishes at the end -- functional, but a little stiff. In section 5 we replace the constant rise with an eased motion (fast pop up, gentle settle, then a fade) using the exact same tween machinery.

Particles

A short particle burst (sparks, dust, blood, magic motes) sells impact at the point of contact. Most engines make this a one-liner once the particle system asset is set up:

hitParticles.transform.position = contactPoint;
hitParticles.Play();

Particles read best combined with a hit flash and a sound at the exact same instant -- three channels confirming the same event tends to feel far more solid than any one of them alone, which is the whole idea behind the next two sections.

3. Audio feedback: a sound for every meaningful action

The single rule that matters most for audio feedback: every meaningful action should make a sound, not just the big ones. Footsteps, menu clicks, picking up an item, taking damage, a cooldown finishing -- silence on any of these reads as "did that even register?" to the player, even if the visual feedback is technically present too. Sound often arrives to the brain faster than a full visual read, which is why it is such a strong "yes, that worked" signal.

The second rule: avoid playing the exact same sample over and over without variation. A machine-gun of footsteps or attacks that sound identical, frame after frame, reads as robotic. A tiny random pitch shift fixes this almost for free:

using UnityEngine;

public class SfxOneShot : MonoBehaviour
{
    public AudioSource source;
    public AudioClip clip;
    [Range(0f, 0.2f)] public float pitchVariance = 0.08f;

    public void Play()
    {
        source.pitch = 1f + Random.Range(-pitchVariance, pitchVariance);
        source.PlayOneShot(clip);
    }
}

A typical sequence of calls (exact numbers differ every run -- chapter 2.6 covered why unseeded Random gives a different sequence each time -- but they always land inside [0.92, 1.08] here):

call 1: pitch = 1.03 call 2: pitch = 0.95 call 3: pitch = 1.07 call 4: pitch = 0.98 close enough that it still sounds like "the same footstep sound", varied enough that four in a row do not sound identical
Tip PlayOneShot (rather than Play) lets multiple copies of the same sound overlap without cutting each other off -- important for fast, repeated actions like a machine gun or a combo attack, where two hits landing one frame apart should both be heard.

4. Haptic feedback: rumble as a channel

Haptics means feedback the player feels physically -- controller rumble, or phone vibration. It is the least-used channel of the three because not every input device supports it, but on the devices that do, it is remarkably effective at selling weight: a short, strong pulse reads as an impact, a long, weak pulse reads as tension or an idling engine.

using UnityEngine;
using UnityEngine.InputSystem;
using System.Collections;

public class RumbleFeedback : MonoBehaviour
{
    public IEnumerator RumblePulse(float lowFreq, float highFreq, float duration)
    {
        var pad = Gamepad.current;
        if (pad == null) yield break;   // keyboard/mouse player, or no controller -- do nothing

        pad.SetMotorSpeeds(lowFreq, highFreq);
        yield return new WaitForSeconds(duration);
        pad.SetMotorSpeeds(0f, 0f);
    }
}

The if (pad == null) yield break; guard matters: not every player has a gamepad plugged in, so haptics must always be an optional extra layer on top of visual and audio feedback, never the only signal an action happened. On mobile, the equivalent is a single call: Handheld.Vibrate(); (a fixed short buzz -- mobile devices generally do not expose fine-grained rumble control the way console controllers do).

5. Easing and tweens: animating without linear motion

Chapter 2.4 covered lerp(a, b, t) and three easing shapes: easeIn(t) = t * t (slow start), easeOut(t) = 1 - (1-t) * (1-t) (fast start, gentle settle), and easeInOut (slow at both ends). Those formulas were pure math there; here they become the backbone of every UI animation and pop effect in this chapter.

A tween (short for "in-betweening", a term borrowed from traditional animation) is code that animates a value from a start to an end over a duration, using an easing function to decide the shape of that motion instead of moving at constant speed. Here is a small, reusable one:

using UnityEngine;

public static class Easing
{
    public static float Linear(float t)  => t;
    public static float EaseIn(float t)  => t * t;
    public static float EaseOut(float t) => 1f - (1f - t) * (1f - t);
    public static float EaseInOut(float t)
    {
        if (t < 0.5f) return 2f * t * t;
        float u = -2f * t + 2f;
        return 1f - (u * u) / 2f;
    }
}
using UnityEngine;
using System.Collections;

public static class Tween
{
    public static IEnumerator Float(float from, float to, float duration,
                                     System.Func<float, float> ease,
                                     System.Action<float> onUpdate)
    {
        float t = 0f;
        while (t < duration)
        {
            t += Time.deltaTime;
            float normalized = Mathf.Clamp01(t / duration);
            float eased = ease(normalized);
            onUpdate(Mathf.LerpUnclamped(from, to, eased));
            yield return null;
        }
        onUpdate(to);
    }
}

Using it to pop a UI panel's scale from 0 to 1:

StartCoroutine(Tween.Float(0f, 1f, 0.1f, Easing.EaseOut, v =>
{
    panel.localScale = Vector3.one * v;
}));

Worked trace at 50 FPS (duration = 0.1s = 5 frames -- the exact same numbers as the easeOut table in chapter 2.4, applied here to an actual scale value instead of an abstract number):

frame t (s) normalized (t/0.1) EaseOut(t) panel.localScale 1 0.02 0.2 0.36 0.36 (36% size) 2 0.04 0.4 0.64 0.64 (64% size) 3 0.06 0.6 0.84 0.84 (84% size) 4 0.08 0.8 0.96 0.96 (96% size) 5 0.10 1.0 1.00 1.00 (full size)
scale 1.0 | * | * 0.8 | * | * 0.6 | * | 0.4 | * | 0.2 | * | 0.0 *----+----+----+----+----+---- t 0.0 0.2 0.4 0.6 0.8 1.0 * = EaseOut curve: big jump early, slows into the landing -- reads as responsive (reacts fast to input) and controlled (no overshoot yet)

Compare that to Linear, which would go 0.2, 0.4, 0.6, 0.8, 1.0 at exactly the same frames -- constant speed, and it stops dead the instant it hits 1.0. That dead stop is what makes linear motion read as "computer-y": nothing in the physical world stops that abruptly. EaseOut covers more distance early (when the input just happened and speed reads as responsiveness) and less distance late (so the stop looks gentle instead of jarring).

Tip In a shipped project you will very often see this exact pattern packaged as a library, most commonly DOTween, as a single line: panel.transform.DOScale(1f, 0.1f).SetEase(Ease.OutQuad);. That call does precisely what the coroutine above does -- animate a value over a duration through an easing curve -- just with dozens of pre-built curves and no boilerplate. Understanding the coroutine version means you are never stuck if a tweening library is unavailable or you need a curve it does not offer.

6. Anticipation and overshoot: popping past the target and settling

Anticipation is a small movement in the opposite direction just before the main action -- a character crouching slightly before a jump, a bow drawing back before it fires. Overshoot (with its settle back down called follow-through) is the opposite end of the same idea: instead of stopping exactly on the target value, the motion goes slightly past it, then eases back. Both come from traditional hand-drawn animation, and both exist for the same reason: real physical things have mass, and mass never starts or stops on a perfect cue -- it winds up and it overshoots.

The clearest place to feel overshoot is a UI element that "pops" into existence -- a button that appears at 0% size, grows past 100%, and settles back to exactly 100%. A plain EaseOut stops precisely at the target with no overshoot at all; getting the pop requires a different curve shape:

public static class Easing
{
    // ... EaseIn / EaseOut / EaseInOut from section 5 ...

    public static float EaseOutBack(float t)
    {
        const float s = 1.70158f;   // overshoot amount -- bigger s, bigger pop
        float u = t - 1f;
        return 1f + u * u * ((s + 1f) * u + s);
    }
}

This one is worth actually running, since it is plain math with no Unity dependency:

using System;

class EasingDemo
{
    static float EaseOutBack(float t)
    {
        const float s = 1.70158f;
        float u = t - 1f;
        return 1f + u * u * ((s + 1f) * u + s);
    }

    static void Main()
    {
        for (int i = 0; i <= 10; i++)
        {
            float t = i / 10f;
            Console.WriteLine($"t={t:0.0}  value={EaseOutBack(t):0.0000}");
        }
    }
}

Output:

t=0.0  value=0.0000
t=0.1  value=0.4088
t=0.2  value=0.7058
t=0.3  value=0.9071
t=0.4  value=1.0290
t=0.5  value=1.0877
t=0.6  value=1.0994
t=0.7  value=1.0802
t=0.8  value=1.0465
t=0.9  value=1.0143
t=1.0  value=1.0000

Notice the value crosses 1.0000 (100%) somewhere around t = 0.35, peaks near 109.9% around t = 0.6, then eases back down to exactly 1.0000 at t = 1.0. That is the whole trick: the curve does not stop the instant it reaches the target, it sails past it and settles, which reads as something with real weight and spring rather than a shape that simply appeared.

value (fraction of final size) 1.10 | *** | ** **** 1.00 | - - - - - - -**- - - - - -**- - - - <- target: settles exactly here at t=1.0 | ** | ** 0.50 | * | * | * 0.00 *---*-------------------------------- t 0.0 1.0 the curve overshoots past 100%, then eases back down to exactly 100%
StartCoroutine(Tween.Float(0f, 1f, 0.25f, Easing.EaseOutBack, v =>
{
    button.localScale = Vector3.one * v;
}));

Anticipation works the same way but in reverse and before the action: for a button press, that might mean a 1-frame squash to 95% size the instant the press is detected, before the release animation eases back up to 100% (or past it, with an EaseOutBack on the way up). Both are cheap: a few extra lines of curve math on top of a system you already built.

7. Screen shake done right: trauma, not raw random

The laziest screen shake implementation offsets the camera by Random.insideUnitSphere every single frame. It works, technically, but it looks bad: a new random position every frame with no relationship to the previous frame reads as flickery static, not a physical shake. Two fixes make screen shake actually feel good: driving the offset from smooth noise instead of pure randomness, and driving the intensity from a decaying value that squares down to zero, instead of a flat "shake for N seconds" timer.

This decaying value is usually called trauma (a term popularized in game-feel talks on camera shake): a number from 0 to 1 that jumps up when something impactful happens, and steadily decays back toward 0 every frame. The actual shake strength is trauma * trauma (squared), not trauma directly:

using UnityEngine;

public class TraumaShake : MonoBehaviour
{
    public float maxOffset = 0.6f;      // world units at full trauma
    public float maxAngle = 8f;         // degrees at full trauma
    public float decay = 1.2f;          // trauma lost per second
    public float noiseFrequency = 25f;  // how fast the Perlin noise scrolls

    float trauma;   // 0..1
    float seed;

    Vector3 basePos;
    Quaternion baseRot;

    void Awake()
    {
        seed = Random.value * 100f;
        basePos = transform.localPosition;
        baseRot = transform.localRotation;
    }

    public void AddTrauma(float amount)
    {
        trauma = Mathf.Clamp01(trauma + amount);
    }

    void Update()
    {
        trauma = Mathf.Max(0f, trauma - decay * Time.deltaTime);

        float shake = trauma * trauma;   // squared -- small hits barely move the camera
        float t = Time.time * noiseFrequency;

        float offsetX = (Mathf.PerlinNoise(seed, t) * 2f - 1f) * maxOffset * shake;
        float offsetY = (Mathf.PerlinNoise(seed + 1f, t) * 2f - 1f) * maxOffset * shake;
        float angle   = (Mathf.PerlinNoise(seed + 2f, t) * 2f - 1f) * maxAngle * shake;

        transform.localPosition = basePos + new Vector3(offsetX, offsetY, 0f);
        transform.localRotation = baseRot * Quaternion.Euler(0f, 0f, angle);
    }
}

Perlin noise (a smooth pseudo-random function, different from the raw Random from chapter 2.6) is sampled here instead of a fresh random number every frame because it is continuous: nearby time values give nearby outputs, so the camera glides between offsets instead of teleporting. Each axis reads from a different point in the noise (seed, seed + 1, seed + 2) so X, Y, and rotation do not all wobble in lockstep.

Common mistake Using raw randomness every frame instead of noise:
// DON'T -- a brand new random offset every single frame looks like static,
// not a shake, because there is no relationship between one frame and the next
transform.localPosition = basePos + Random.insideUnitSphere * shake;
Perlin noise fixes this because it is a continuous function of time -- consecutive frames sample nearby points on a smooth curve, instead of jumping to a completely unrelated value.

Worked trace: a big hit calls AddTrauma(1f), then nothing else happens. With decay = 1.2 trauma/second:

t (s) 0.00 0.20 0.40 0.60 0.80 0.83 trauma 1.00 0.76 0.52 0.28 0.04 0.00 shake=trauma^2 100% 57.8% 27.0% 7.8% 0.2% 0.0% shake=trauma 100% 76.0% 52.0% 28.0% 4.0% 0.0% (for comparison, NOT squared)

Squaring makes the falloff feel like a sharp punch that fades fast: at t = 0.2s, only 0.2 seconds after a huge hit, the squared shake has already dropped to 58% and keeps falling hard, while the un-squared version is still sitting at a strong 76% and stays noticeably shaky for much longer. A sharp initial hit that tapers off quickly reads as an impact; a shake that lingers at high strength for a while reads as motion sickness waiting to happen.

8. Hit-stop: freezing time on impact

Hit-stop (also called "hit-freeze" or "time freeze") pauses -- or nearly pauses -- the game for a handful of frames at the exact moment of impact, before resuming at normal speed. It is one of the cheapest tricks for making a hit feel heavy: the brief freeze gives the eye a moment to register that something significant just happened, the same way a photograph freezing an action peak makes it look more powerful than a smooth video of the same motion.

using UnityEngine;
using System.Collections;

public class HitStopController : MonoBehaviour
{
    public IEnumerator HitStop(float duration, float freezeScale = 0.02f)
    {
        float previousScale = Time.timeScale;
        Time.timeScale = freezeScale;

        yield return new WaitForSecondsRealtime(duration);

        Time.timeScale = previousScale;
    }
}
time -> 0.00s 0.02s 0.07s | | | normal speed IMPACT: freeze begins resume Time.timeScale = 0.02 Time.timeScale = 1.0 held for 0.05s of REAL time (measured with WaitForSecondsRealtime)

Time.timeScale multiplies every scaled time value in the whole game at once -- Time.deltaTime, physics steps, most animations -- which is exactly why setting it near zero freezes gameplay so cheaply: nothing that reads scaled time needs to know a freeze is happening.

Common mistake Waiting out the freeze with the wrong timer:
// BUG: WaitForSeconds counts SCALED time. If Time.timeScale is close to
// zero, this wait takes almost forever in real life -- the game appears
// to freeze permanently, because the very thing counting down the freeze
// is itself slowed down by the freeze.
yield return new WaitForSeconds(duration);
WaitForSecondsRealtime ignores Time.timeScale entirely, so it always counts real, wall-clock seconds -- which is the only sane way to time how long a time-freeze itself should last.

A related detail: anything driven by Time.deltaTime freezes along with gameplay, but UI is commonly animated with Time.unscaledDeltaTime specifically so it keeps moving smoothly through a hit-stop -- a health bar or combo counter that visibly reacts during the freeze reinforces that the freeze was intentional, rather than looking like the game hung.

9. Responsiveness: answering within a frame, buffering input, coyote time

Everything so far has been about making a response feel good once it happens. This section is about when it happens. The rule: the delay between an input and any feedback for it should be as close to a single frame as the engine allows, even if the "real" outcome -- a full animation finishing, a server confirming a hit, a damage calculation resolving -- takes longer. A button should visibly depress the instant it is clicked; a character should visibly begin a windup the instant attack is pressed, even if the hit itself lands three frames later.

frame N: input read --> immediate feedback fires THIS SAME FRAME | +--> visual: button depresses / windup pose starts +--> audio: click / windup sound plays (the SLOW part -- full animation, a network round-trip, damage resolution -- can still take several more frames, starting AFTER this)

Chapter 6.2 built two specific tools for the times when the "correct" moment and the player's actual button press do not quite line up: coyote time (forgiving a jump pressed slightly late, after leaving the ground) and jump buffering (forgiving a jump pressed slightly early, before landing). Both were built from the same shape: a countdown timer that resets on a triggering event and is checked against zero. That shape generalizes to any input that needs a small forgiveness window, not just jumping:

public class BufferedInput
{
    public float bufferTime = 0.15f;
    float timer;

    public void Press()
    {
        timer = bufferTime;
    }

    public void Tick(float deltaTime)
    {
        timer = Mathf.Max(0f, timer - deltaTime);
    }

    public bool Consume()
    {
        bool active = timer > 0f;
        if (active) timer = 0f;
        return active;
    }
}
BufferedInput attackInput = new BufferedInput();

void Update()
{
    if (Input.GetButtonDown("Attack"))
        attackInput.Press();

    attackInput.Tick(Time.deltaTime);

    if (isReadyForNextHit && attackInput.Consume())
        DoAttack();
}
Tip This exact pattern -- a small forgiveness window plus immediate feedback on the input itself -- covers most of what players describe as "the controls feel unfair" or "the controls feel unresponsive." It is rarely a single big bug; it is usually one frame of missing feedback here, one missing buffer window there, stacked up.

10. Accessibility: juice that some players need to turn off

Every effect in this chapter needs to be adjustable, because the same juice that makes the game feel great for one player can make it unplayable for another. Accessibility here is not a separate feature bolted on afterward -- it is a set of knobs that every system in this chapter should already expose.

Colorblind-safe design

A meaningful share of players, most commonly men, have some degree of color vision deficiency, most often trouble distinguishing red from green. Any feedback that relies on color alone -- a red "enemy" outline versus a green "ally" outline, a red health bar meaning "danger" -- can be invisible to exactly the players who need it most. The fix is always the same: pair color with a second signal (a shape, an icon, a different fill pattern, a position on screen) so the information survives even if the color does not read correctly.

Common mistake Encoding a critical piece of information -- "this is an enemy", "this attack is unblockable", "you are low on health" -- in color and nothing else. Test any such indicator by converting a screenshot to grayscale; if the meaning disappears, so will it for some real players.

Subtitles and sound cues

Subtitles usually mean dialogue text, but the same idea applies to every sound-only piece of gameplay feedback: a low-ammo beep, off-screen footsteps warning of an enemy, an alarm. A player who is deaf, hard of hearing, or simply playing muted in a shared room should still get that information through an on-screen cue (an icon, a directional indicator, a caption like "[footsteps, behind]").

Remappable controls

Not every player can comfortably reach every default key or button. Reading input through named actions instead of hardcoded keys (an input system with rebindable action maps, rather than checking KeyCode.Space directly all over the codebase) is what makes remapping possible at all -- it is far cheaper to design for from the start than to retrofit later.

Reduced motion

Screen shake, strobing flashes, and rapid zooms can trigger motion sickness in some players, and rapid flashing specifically (roughly in the 3-30 times-per-second range) is a known trigger for photosensitive seizures in a small number of people. A reduced motion option should be able to turn these effects down or fully off without breaking the rest of the game's feedback -- the player should still know a hit landed, just without the camera violently moving to tell them.

[System.Serializable]
public class AccessibilitySettings
{
    public float shakeIntensity = 1f;        // 0 = no camera shake at all
    public bool  colorblindIcons = true;     // pair color cues with shapes/icons
    public bool  screenFlashReduced = false; // cap hit-flash brightness
    public bool  subtitlesForSfx = false;    // caption non-dialogue sound cues
}

Wiring it into systems already built in this chapter is a one-line change each:

// in TraumaShake.Update()
float shake = trauma * trauma * settings.shakeIntensity;

// in HitFlash.FlashRoutine()
float peak = settings.screenFlashReduced ? 0.4f : 1f;
sr.color = Color.Lerp(baseColor, flashColor, peak);

Because every juice system in this chapter already reads its strength from one number (trauma, a flash's peak color, a duration), adding an accessibility multiplier on top of it is nearly free -- which is exactly why it is worth building the systems this way from the start, rather than hardcoding "shake by 0.6" directly at every call site.

11. The danger of overdoing it

Every technique in this chapter has an off switch for a reason beyond accessibility: overused, all of them make a game worse, not better. Three specific failure modes show up constantly.

Juice fatigue. If every action -- a tiny jab, a footstep, opening a menu -- gets the same maximum shake, flash, and sound, nothing feels special anymore, because there is no contrast between a small moment and a huge one. Juice needs a dynamic range, the same way music needs quiet passages for the loud ones to land.

hit size trauma added hit-stop length sound tiny (jab) 0.05 0-1 frames quiet tick medium (hit) 0.15 2-4 frames normal thwack big (crit) 0.35 4-8 frames louder crunch huge (kill) 0.70 10-14 frames big impact + brief slow-mo if EVERY hit used the "huge" row, players would lose the ability to tell a jab from a kill blow just by watching and listening to the screen

Obscured information. Screen shake violently moving the camera during a bullet-hell pattern, or a rhythm game's timing window, actively hides the exact information the player needs to react correctly. This is why competitive and precision-heavy genres (fighting games, top-level shooters, rhythm games) deliberately run with far less shake than a cinematic action game -- they favor hit-flash and short hit-stop, which communicate impact without moving the camera the player is reading.

Reduced responsiveness. A hit-stop of 2 to 6 frames on a normal hit reads as weight. A hit-stop of 30 frames on every single hit reads as lag -- the exact opposite of the "respond within a frame" rule from section 9. Save the long freezes for genuinely rare moments (a killing blow, a boss stagger) specifically so they still feel special when they happen.

Tip A good habit before shipping any juice effect: try turning it off entirely and play the same scenario both ways. If turning it off makes the moment more readable without making the game feel worse, that effect was covering for a lack of clarity rather than adding real feel -- dial it back rather than turning it up further.

12. Glossary

13. Exercises

Exercise 1 Using the EaseOutBack formula from section 6 (s = 1.70158, u = t - 1, result = 1 + u*u*((s+1)*u + s)), compute the eased value at t = 0.5 by hand, showing your work. Then state the value at t = 1.0 without recomputing, and explain in one sentence why that specific value makes sense for a UI element that is supposed to "pop" into place.
Show answer
t = 0.5
u = 0.5 - 1 = -0.5
u*u = 0.25
(s+1)*u = 2.70158 * (-0.5) = -1.35079
(s+1)*u + s = -1.35079 + 1.70158 = 0.35079
u*u * 0.35079 = 0.25 * 0.35079 = 0.0876975
result = 1 + 0.0876975 = 1.0877

So at the halfway point in time, the element is already at about 108.8% of its final size -- past the target, mid-overshoot. At t = 1.0 the value is exactly 1.0000 (100%), because the curve is specifically built to always land exactly on the target at the end no matter how far it overshoots along the way. That matters for a UI pop: the element needs to visibly settle at its real, final size, not stay stuck slightly too big or too small -- the overshoot is only a mid-animation flourish, never the resting state.

Exercise 2 A hit adds trauma = 0.5 (starting from 0, clamped to the 0..1 range). decay = 1.5 trauma per second, and no further hits land. Using shake = trauma * trauma, compute trauma and shake at t = 0.1s, t = 0.2s, and t = 0.3s. Then compute the exact time at which trauma reaches zero.
Show answer

trauma(t) = max(0, 0.5 - 1.5*t):

t=0.1s:  trauma = 0.5 - 1.5*0.1 = 0.35   shake = 0.35*0.35 = 0.1225  (12.25%)
t=0.2s:  trauma = 0.5 - 1.5*0.2 = 0.20   shake = 0.20*0.20 = 0.0400  (4.00%)
t=0.3s:  trauma = 0.5 - 1.5*0.3 = 0.05   shake = 0.05*0.05 = 0.0025  (0.25%)

Trauma reaches exactly zero when 0.5 - 1.5*t = 0, so t = 0.5 / 1.5 = 0.333s (about a third of a second). Notice how fast the squared shake value collapses: by t = 0.2s, only 60% of the way through the shake's lifetime, it is already down to 4% strength -- barely perceptible -- even though trauma itself is still at a very noticeable 20%. This is exactly the sharp-punch-then-fade behavior squaring is meant to produce.

Exercise 3 A tester says a game's melee combat "feels weak," even though the damage numbers and hit detection already work correctly, and every hit already triggers a large screen shake. Using at least four separate techniques from this chapter (not just "add more shake"), list specific changes you would try, and say what problem each one is meant to fix.
Show answer

A reasonable set of changes, none of which touch the actual damage numbers:

  • Add hit-stop (section 8). A 2-6 frame freeze right on impact gives the eye a moment to register the hit as a distinct, weighty event, rather than one frame blending into the next during fast combat.
  • Switch to trauma-based shake, scaled by hit size (sections 7 and 11). If every hit already uses the same "large shake," there is no dynamic range -- a weak jab and a heavy finisher feel identical, so nothing feels special. Scaling AddTrauma() by hit strength (small jabs add a little, heavy hits add a lot) restores that contrast, and might mean actually turning the shake for ordinary hits down.
  • Add a hit flash and a number popup (section 2). Instant, unmissable visual confirmation the exact frame the hit connects, independent of whatever the camera is doing.
  • Add a sound with pitch variance on every hit (section 3). If hits are currently silent or reuse one identical sample, this alone often reads as a big jump in punchiness.
  • Check responsiveness (section 9). If there is a delay between pressing attack and the very first bit of feedback (even the windup animation starting), the attack will feel sluggish no matter how much juice is added to the impact itself -- the windup needs to start the same frame the button is read.

The tester's report is a strong hint that simply adding more of the same effect (more shake) will not fix it -- section 11 covers exactly this: once an effect is already maxed out, more of it stops adding perceived weight and starts adding noise. The fix is usually a missing different channel (hit-stop, flash, sound, or responsiveness), not a bigger dose of the one channel already in use.

← Back to all chapters