Skip to content

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_changed signals 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. A Tween ramps the sprite's modulate to white and back over ~0.1 s. Quick to write, no extra resources.
  • AnimationPlayer flash track. A reusable hit_flash animation keys modulate white→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

  1. Enrich the hit path to carry the source. In hurtbox.gd (M3.1), change the signal to signal hurt(amount: int, source_position: Vector2) and emit hurt.emit(hitbox.damage, hitbox.global_position). Update the Health connection: the actor now connects hurt to a small handler that both damages and reacts, rather than directly to take_damage.
  2. In the actor (player and, later, enemies), add a hurt handler:
    func _on_hurt(amount: int, source_position: Vector2) -> void:
        health.take_damage(amount)
        _flash()
        apply_knockback(source_position, 250.0)
    
    Connect hurtbox.hurt.connect(_on_hurt) in _ready (replacing the direct take_damage connection from M3.2).
  3. Add the _flash() method (Tween version) and the knockback field + apply_knockback + the knockback decay 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 set velocity), or fold it into each state.
  4. (Optional) Add hitstop: put the hitstop method 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 call hitstop from the enemy's _on_hurt for heavy hits only.
  5. Press F5 and attack an enemy (or temporary hurtbox target). Confirm the target flashes white and is shoved away from the player, with the shove easing out. Tune knockback strength and knockback_friction until 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.