M3.4 — Damage Feedback (Juice)¶
What you'll learn
- Add three feedback cues — hit flash, knockback, hitstop — riding the M3 damage signals.
- Flash with a
Tween, and apply a decaying knockback impulse away from the attacker. - Apply hitstop (a brief impact freeze) without breaking the simulation.
How it applies
- Feedback is how the player knows the hit landed. The damage number changed in memory, but the player is looking at the screen, not the debugger. A flash and a shove are the game telling the player "that connected." Without them, combat feels mushy and players spam attacks unsure whether anything is happening — a complaint that reads as "the combat feels bad" even when every number is correct.
- Knockback is also a mechanic, not just a cue. Pushing enemies back creates space, interrupts their attacks, and is the basis of crowd control. Tuning its strength is tuning the game's pace.
- Hitstop sells weight. Freezing both actors for a few frames on a heavy hit makes the blow feel like it has mass. It is a few lines and disproportionately responsible for whether a game feels "crunchy."
- Feedback rides the existing signal. All three cues hang off the same
hurt/health_changedsignals from M3.1–M3.2 — no new plumbing, just new listeners. This is the payoff of routing damage through signals: presentation attaches without touching combat logic.
Concepts¶
Hit flash¶
The cheapest, most legible cue is briefly tinting the hurt sprite — usually flashing it white — for a fraction of a second. Two ways:
- Tween
modulate. ATweenramps the sprite'smodulateto white and back over ~0.1 s. Quick to write, no extra resources. AnimationPlayerflash track. A reusablehit_flashanimation keysmodulatewhite→normal. Tidy if you want the same flash on many actors and to trigger it by name.
A truer "solid white" flash (ignoring the sprite's own colors) uses a small shader that outputs white
where the texture is opaque, driven by a flash_amount uniform. Start with modulate; reach for the
shader when the tint-vs-replace difference matters to your art.
Example
A Tween-based flash, as a method on the actor, triggered from the hurt signal:
func _flash() -> void:
var t := create_tween()
sprite.modulate = Color(4, 4, 4) # over-bright white (values > 1 with HDR/2D blow out)
t.tween_property(sprite, "modulate", Color.WHITE, 0.12)
create_tween() makes a one-shot tween bound to the node; it animates modulate from the bright
flash back to normal over 0.12 s, then frees itself. Calling _flash() again mid-flash creates a
fresh tween — fine for rapid hits.
Knockback¶
On hit, push the victim away from the attacker. The direction is from the attacker to the victim; the strength is a tunable. Apply it as a velocity the actor's movement decays each frame, so the shove eases out rather than teleporting.
The victim needs to know where the hit came from. Extend the hit path to carry the source position: the
hitbox knows its own global_position, so the hurtbox can pass it along. (You can enrich hurt to
hurt(amount, source_position) — a small signature change that every later cue benefits from.)
Example
Add a knockback velocity to the actor and decay it in _physics_process. The actor stores a
knockback vector; movement adds it and bleeds it toward zero:
var knockback: Vector2 = Vector2.ZERO
@export var knockback_friction: float = 1200.0 # px/s^2 the shove decays at
func apply_knockback(from_position: Vector2, strength: float) -> void:
var dir := (global_position - from_position).normalized()
knockback = dir * strength
func _physics_process(delta: float) -> void:
knockback = knockback.move_toward(Vector2.ZERO, knockback_friction * delta)
# the FSM state sets velocity from input; add the decaying knockback on top:
velocity += knockback
move_and_slide()
move_toward shrinks the knockback vector toward zero by a fixed amount per second, so the shove
decays smoothly regardless of frame rate. Because the FSM state sets velocity from input first and
this adds knockback on top, a hit shoves the actor even mid-move, then fades.
A design choice: while in a Hurt state (optional), you can ignore input entirely and let only the
knockback drive velocity, so the player briefly loses control on a heavy hit — "hitstun." Whether to
add a Hurt state is a feel decision; the FSM makes it a clean addition if you want it.
Hitstop¶
Hitstop freezes the action for a few frames on impact to punctuate the blow. The simplest implementation scales engine time briefly:
Example
A short global hitstop via Engine.time_scale, restored after a real-time wait:
func hitstop(duration_seconds: float = 0.06, scale: float = 0.0) -> void:
Engine.time_scale = scale
# wait in real time, unaffected by time_scale, then restore:
await get_tree().create_timer(duration_seconds, true, false, true).timeout
Engine.time_scale = 1.0
The create_timer(..., ignore_time_scale = true) flag makes the wait real-time so it doesn't itself
get frozen by time_scale = 0. Use sparingly — hitstop on every hit is exhausting; reserve it for
heavy or finishing blows. Because it is global, fire it from one place (e.g., the player's attack
landing), not from every hurtbox.
All three ride the existing signals¶
None of this required new combat plumbing. The flash and knockback are listeners on the victim's hurt
signal (M3.1); they could equally hang on health_changed (M3.2). Hitstop is triggered where an attack
lands (the attacker's side). Presentation is layered onto the damage events that already exist — which
is exactly why those events were modeled as signals in the first place.
Walkthrough¶
- Enrich the hit path to carry the source. In
hurtbox.gd(M3.1), change the signal tosignal hurt(amount: int, source_position: Vector2)and emithurt.emit(hitbox.damage, hitbox.global_position). Update the Health connection: the actor now connectshurtto a small handler that both damages and reacts, rather than directly totake_damage. - In the actor (player and, later, enemies), add a hurt handler:
Connect
func _on_hurt(amount: int, source_position: Vector2) -> void: health.take_damage(amount) _flash() apply_knockback(source_position, 250.0)hurtbox.hurt.connect(_on_hurt)in_ready(replacing the directtake_damageconnection from M3.2). - Add the
_flash()method (Tween version) and theknockbackfield +apply_knockback+ theknockbackdecay in_physics_process. If your movement lives entirely in FSM states, add the knockback decay/application in the body's own_physics_process(it can coexist with the states, which setvelocity), or fold it into each state. - (Optional) Add hitstop: put the
hitstopmethod on the player and call it when the player's attack actually lands — e.g., have the player's Hitbox emit a "dealt damage" signal, or callhitstopfrom the enemy's_on_hurtfor heavy hits only. - Press
F5and attack an enemy (or temporary hurtbox target). Confirm the target flashes white and is shoved away from the player, with the shove easing out. Tuneknockbackstrength andknockback_frictionuntil it feels right.
Optional sanity check
Set knockback strength very high (e.g., 2000) temporarily and confirm the target is flung but still
decays to a stop rather than flying forever — proof move_toward is bleeding it off. Then set it to
0 and confirm hits still flash and still damage — proof the three cues are independent and ride
separate code paths. Restore a value that feels good.
Self-check quiz¶
Q1 — Why does adding hit flash, knockback, and hitstop require no changes to the combat detection from M3.1–M3.2?
A. They are built into Area2D.
B. They are new listeners on the existing hurt/health_changed signals (and the attack-lands event),
so presentation attaches to the damage events already modeled, without touching detection.
C. The engine adds them automatically when HP changes.
D. They replace the hurtbox entirely.
Reveal answer
B. Damage was routed through signals precisely so that anything interested — a health bar, a flash, a knockback, a death handler — can subscribe without the detection code knowing about it. The cues are additional subscribers, not modifications. A and C are fabricated. D is wrong — they layer on top of the hurtbox, which still does its job.
Q2 — Why apply knockback as a decaying velocity (via move_toward) rather than instantly setting the victim's position away from the attacker?
A. Setting position is impossible in Godot.
B. A decaying velocity reads as a physical shove that eases out and respects collisions via
move_and_slide; teleporting the position looks instant and can place the victim inside a wall.
C. move_toward is the only way to change Vector2.
D. Instant position changes disable the camera.
Reveal answer
B. Knockback as velocity flows through move_and_slide, so it slides along walls and looks
like momentum that bleeds off; a direct position set is a discontinuous jump that ignores
collisions and can teleport the victim into geometry. A and C are false. D is fabricated.
Q3 — Why must the hitstop timer use the 'ignore time scale' flag?
A. To make the timer more accurate.
B. Because hitstop sets Engine.time_scale to 0; a normal timer is scaled by time_scale and would
also freeze, so the wait must run in real time to ever resume.
C. Because timers can't run during _physics_process.
D. To make the timer global.
Reveal answer
B. With time_scale = 0, everything scaled by engine time stops — including an ordinary
SceneTreeTimer. If the very timer meant to end the freeze is itself frozen, time never resumes.
The ignore-time-scale flag makes that timer tick in real seconds so it fires and restores
time_scale to 1. A, C, D are not the reason.
Integration question¶
Q4 — open
Damage feedback closes M3. Explain how the chapter's three cues demonstrate the architectural thesis of the whole module — that combat is built from small, signal-connected parts — by naming, for each cue, which earlier signal it subscribes to and what would have been required to add these cues if damage had instead been handled by one monolithic combat function that directly subtracted HP.
Reveal expected answer
Each cue is a subscriber to an event the combat system already emits: the hit flash and
knockback hang off the hurtbox's hurt(amount, source_position) signal (M3.1) — the flash
needs only the fact of the hit, the knockback needs the source position the enriched signal now
carries; hitstop fires off the attack-lands event on the attacker's side. None of them touch
the detection logic. The thesis of M3 is that combat is a mesh of small components communicating
by signal: the hitbox tags damage, the hurtbox detects and announces, the Health clamps and
emits, and presentation listens. Had damage been a single monolithic function that detected
overlap and directly subtracted HP, adding these cues would mean editing that function for every
cue — threading flash, knockback, and hitstop calls into the one place that does everything, and
re-editing it for the next cue and the next actor. The signal-based design means a cue is a new
connect, not a new branch in a growing function, which is why the player and every M4 enemy can
opt into different feedback by connecting the same signals differently rather than by forking
combat code.