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.
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.
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.
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.
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):
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.
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.
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.
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):
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.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).
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):
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).
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.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.
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.
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.
// 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:
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.
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.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.
// 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.
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.
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();
}
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.
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.
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]").
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.
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.
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.
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.
0..1 progress value into a non-constant motion curve.Time.timeScale, used so real-time waits and UI animation can keep running through a freeze.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.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.
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.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.
A reasonable set of changes, none of which touch the actual damage numbers:
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.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.