doc 2 of 5

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.1 set, and a timer to restore it to 1.0. The timer is a SceneTreeTimer from get_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:

One hit, the damage frame and its aftermath. 40 frames at 60fps, 0.67 seconds total: fact 1 frames (0.02s), stop 2 frames (0.03s), flash 6 frames (0.10s), settle 31 frames (0.52s). The timeline marker runs at 3× slower than real time.The flash and the stop both leave the fact frame — the damage signal. The stop holds the world for two real frames; the flash decays over six. The thock (the audio shelf's part 04) plays on the fact frame too, and because time_scale doesn't touch the audio clock, it punches through the dip instead of smearing in it.

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 modulate to white is instant, the decay is six frames EASE_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 SceneTreeTimer version, 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 modulate is on the wrong node (the root, and the skin's own modulate is overriding it — a child's modulate multiplies the parent's, and a skin at Color.WHITE under a root at Color.WHITE is 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 base modulate is 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 _process is gated), and the dip gated the tween. The flash must run on the render (the modulate is 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 _process is 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.