Part 01 — The hit lands
You will build: the hit-flash (the target's white-and-back, on the damage frame) and the
hit-stop (the two-frame world dip, on the same frame), in the order that makes the hit land
instead of happen.
You'll learn: the damage frame as the consequence's home · Engine.time_scale and what
it does (and doesn't) stop · and the input tax a hit-stop charges, budgeted.
Why this exists#
The survivors stage 04 built the damage path: the hitbox's active frame, the
damage_received signal, the float text, the health change. Add nothing else and the hit
works — the number moves, the text rises, the target dies on schedule. Add nothing else and
the hit also doesn't land — the player has to look at the number to know the sword
connected, because the world gave no answer. This part is the answer: two effects, one frame,
the order that makes the frame felt.
Build it#
A — The frame, and what rides it#
The damage frame is the hitbox's active tick — the survivors stage 05's phase-timeline, the
four frames of danger where the shape is enabled and the overlap fires. That frame is the
event, and the event's home is the damage_received signal (stage 04), which is where the
float text already leaves from. The flash and the stop leave from the same signal, the same
frame — the overview's rule, the consequence rides the event, installed for the first time:
# in the entity that takes the hit (the EnemyStats, stage 04's shape)
func _on_damage_received(amount: int) -> void:
health -= amount
# ... the float text, as stage 04 has it ...
_flash()
_stop()
func _flash() -> void:
var skin := parent_enemy.get_node("SkinRoot/Skin")
var tween := create_tween()
tween.tween_property(skin, "modulate", Color.WHITE, 0.0) # the pop, instant
tween.tween_property(skin, "modulate", Color.WHITE * 1.0, 0.1) # back, 6 frames
tween.set_trans(Tween.TRANS_QUAD).set_ease(Tween.EASE_OUT)
func _stop() -> void:
Engine.time_scale = 0.1
# the restore — write it with a SceneTreeTimer, and read part B before you run it:
# get_tree().create_timer(______).timeout.connect(func(): Engine.time_scale = 1.0)
(The _stop restore is deliberately a blank — and part B is the trap in the obvious way to
fill it. Build the flash first; it's the one that works the first time.)
The flash is the target answering: a modulate to white (the pop — tween_property to
Color.WHITE with a 0.0 duration is the instant set, the hit's frame) and back over six
frames (0.1 s at 60 fps, the EASE_OUT so the white decays rather than fades linearly).
The flash is on the skin, not the scene root — a CollisionShape2D has no modulate, and
a root modulate flashes the hitbox's debug shape along with the sprite (the "why is my
collider white" bug, the node you tuned). The flash's duration is the design number: six
frames is the pop that reads as impact; twelve is a blink; three is a flicker the eye
skips. Part 04's tuning loop is where you find your six.
B — The stop, and the restore trap#
The hit-stop is the world dipping: Engine.time_scale scales the process clocks
(physics and render deltas — the audio shelf's part 04, the two clocks the game owns), so a
time_scale of 0.1 runs the world at a tenth speed for the stop's length. Two frames at a
tenth is the dip: the world stutters on the hit, and the stutter is the mass.
The trap is the restore, and the incomplete _stop above is the trap, named:
Predict before you run:
Engine.time_scale = 0.1set, and a timer to restore it to1.0. The timer is aSceneTreeTimerfromget_tree().create_timer— whose clock does it run on, and what does that do to a restore scheduled for "two frames"?
A SceneTreeTimer runs on the process clock — the clock time_scale just scaled. A
restore scheduled for "two frames" at time_scale = 0.1 takes twenty real frames to fire,
because the timer's two frames are the world's two frames, and the world is at a tenth. The
stop that was meant to last two frames lasts twenty — the world stutters for a third of a
second, and the "two-frame hit-stop" is a twenty-frame freeze. The trap is the restore riding
the clock it's trying to restore.
The fix is a restore that doesn't ride the scaled clock — count real frames, not process-frame:
func _stop() -> void:
Engine.time_scale = 0.1
_stop_frames = 2 # real frames to hold the dip
func _process(_delta: float) -> void:
if _stop_frames > 0:
# _process still runs (time_scale scales delta, not the call) — count the real frame
_stop_frames -= 1
if _stop_frames == 0:
Engine.time_scale = 1.0
_process is called every frame regardless of time_scale (the scale changes the delta
passed in, not whether the callback fires) — so a real-frame counter in _process counts the
display's frames, not the world's, and the restore lands on the second real frame after the
dip started. Two frames of dip, restored on the real clock, the trap gone. (The alternative —
a time_scale tween, tween_property(Engine, "time_scale", 1.0, ...) — rides the same
scaled clock and has the same trap; the real-frame counter is the restore that's honest.)
The stop's length is the design number, and it's the tax: two frames is the dip that reads
as impact; three is the dip that reads as lag. The input tax is the reason it's two —
the audio shelf's part 04 noted
time_scale stretches the process clocks, and stretched clocks stretch input polling: a
press in the dip's two frames is read at a tenth speed, and a player mashing through the dip
loses the frames the dip eats. Two frames is the tax that's felt as weight and not as
drop; three is where the mash starts to lose. Part 04's tuning loop finds your two — and
the finding is usually "two, and no more."
C — The order: flash, then stop, then the world resumes#
The two effects are one frame apart in intent but the order is the feel:
Read the bar the way the eye does: the fact (one frame — the damage, the signal) is where the flash, the stop, and the thock all start. The stop (two frames) holds the world on the hit — the dip. The flash (six frames) decays over the dip and past it — the white is still fading when the world resumes, which is why the flash reads as the hit's aftermath and not its cause. The settle (the rest) is the world back at speed, the target back to colour, the health bar where the number said it would be.
The order's load-bearing fact: the flash starts on the fact frame and runs past the stop;
the stop holds the world and lets the flash (and the thock) run. If the flash waited for
the stop to end (started on frame 3, after the dip), the white would arrive after the
impact — the confirmation late, the audio shelf's part 04 bug in visual form. The flash and
the thock both punch through the dip (the flash because modulate is a render property the
dip doesn't gate, the thock because the audio clock isn't scaled), and the punch-through is
the feel: the world stops, the consequence doesn't. A hit-stop that stops the flash is a
hit-stop that stopped the hit.
D — The input tax, budgeted#
The stop's two frames are a cost the player pays in input latency (part B's mash). The budget is the design call: the stop is worth two frames on a player-caused hit (the sword's swing — the player committed to the input, the dip is the commitment's weight) and not worth it on an ambient hit (a golem's contact damage, the survivor's stage 04 stretch — the player didn't commit, the dip is a tax on a hit they didn't cause). The rule: the hit-stop is the attacker's weight, charged on the attack's fact frame, and the contact damage — the hit that finds the player — gets the flash and the thock but not the stop. The player's sword lands; the golem's touch happens. The distinction is the feel's honesty — a stop on every hit is a stop on the hits the player didn't earn, and the tax is charged on the player's own inputs for the game's ambient events.
E — Commit#
git add . && git commit -m "feel 01: hit-flash and hit-stop on the damage frame, the restore trap"
Checkpoint — definition of done#
- The flash pops on the damage frame (the
modulateto white is instant, the decay is six framesEASE_OUT) — and it's on the skin, not the root (the collider isn't white) - The stop holds the world for two real frames — the restore trap met (the
SceneTreeTimerversion, predicted and watched: the twenty-frame freeze) and fixed (the real-frame counter in_process) - The flash punches through the dip (it's still fading when the world resumes) — the order from part C, the bar read as the eye reads it
- The thock (if the audio shelf's part 01 is built) plays during the dip, unsmeared — the audio clock isn't scaled, and the punch-through is the sound's, too
- The stop is on the player's hits, not the contact damage (part D's budget) — the golem's touch flashes and thocks but doesn't stop the world
- You can say the stop's length in real frames and the input tax it charges (the mash's lost frames), and why two is the weight and three is the lag
- Zero warnings; committed
Stretch (no instructions)#
A hit-stop that scales with the hit: the stop's length is 1 + (amount / max_hit)
frames — a chip damage gets one frame (the dip barely registers), a critical gets three (the
world stops). The tax (part B) scales with the hit's size, which is the design's honest
version: the big hit is the one the player committed to, and the big hit's weight is the
frames it earns. Find the max_hit that makes the critical's three frames feel like impact
and the chip's one frame feel like nothing — the tuning loop, part 04, on the stop's
length.
If you get stuck#
- The flash is invisible (the target doesn't turn white) → the
modulateis on the wrong node (the root, and the skin's ownmodulateis overriding it — a child'smodulatemultiplies the parent's, and a skin atColor.WHITEunder a root atColor.WHITEis white, but a skin at a tinted colour under a root flash is the tint, not the white). The flash is on the skin (the visible leaf), and the skin's basemodulateis the colour the flash returns to. - The stop freezes the flash (the white holds for the dip's length, then decays) → the
flash's tween is riding the process clock (a
create_tween()on a node whose_processis gated), and the dip gated the tween. The flash must run on the render (themodulateis a render property, and the tween that drives it must not be scaled) — the punch-through, part C. If the tween is process-bound, the flash waits for the world; the feel is the flash not waiting. - The restore never fires (the world stays at a tenth) → the real-frame counter's
_processis on a node that's paused or *freed` during the dip (the counter's host died, the count stopped). The counter lives on a node that outlives the dip (the entity, or an autoload) — the containment, the performance shelf's part 03, applied to a two-frame timer. - The stop makes the game feel laggy (not weighty) → the length is past two (part B's tax), or the stop is on the contact hits (part D's budget — the ambient dip, the tax on a hit the player didn't earn). Shorten to two, move the stop off the contact, and the lag is the weight again. The feeling is the measurement; part 04 is where you name it.