# Godot 4 — Hit Feedback Pipeline

> **Purpose**: Reference document for Claude Code. The complete hit feedback pipeline — hitstop, flash, knockback, screenshake, particles, damage numbers — with tested values from shipped games and the exact execution order that makes hits feel heavy.

---

## 1. The Pipeline Order (And Why It Matters)

When a hit connects, effects fire in a specific sequence. Get the order wrong and the hit feels mushy — the player sees chaos instead of impact.

```
Frame 0:   HIT CONNECTS
           ├─ Hitstop starts (both attacker + target freeze)
           ├─ Hit flash on target (white overlay, instant)
           ├─ Impact particles spawn (burst at contact point)
           ├─ SFX plays (the "crunch")
           └─ Damage number spawns (but hidden during hitstop)

Frame 3-5: HITSTOP ENDS
           ├─ Knockback velocity applied to target
           ├─ Screenshake fires (directional, away from hit)
           ├─ Hit flash fades (2-3 more frames)
           ├─ Damage number becomes visible + pops
           └─ Attacker recovery begins

Frame 8+:  AFTERMATH
           ├─ Knockback decelerates (exponential)
           ├─ Screenshake decays
           ├─ Particles dissipate
           └─ Target enters hitstun / invincibility frames
```

**Why hitstop comes first**: the freeze gives the player's eye a moment to register the collision point. Without it, the knockback and particles happen so fast the brain misses the moment of impact. Street Fighter figured this out in 1991.

**Why screenshake is delayed**: if the camera shakes during hitstop, the freeze feels broken. Screenshake should start when motion resumes — it punctuates the release of energy, not the contact.

**Why flash is instant but fades late**: the flash marks "something happened HERE." It persists through hitstop as a visual anchor, then fades as other effects take over.

---

## 2. Hitstop (Frame Freeze)

### What the Player Feels

A heavy hit where the world stutters for a split second. Light hits feel crisp and quick. Heavy hits feel like time itself flinched.

### Values From Shipped Games

| Context | Duration | Source |
|---------|----------|--------|
| Light attack | 40-60ms (2-3 frames @60fps) | Street Fighter light normals: ~9f hitstop at 60fps |
| Medium attack | 65-85ms (4-5 frames) | Street Fighter medium normals: ~11f |
| Heavy attack | 85-120ms (5-7 frames) | Street Fighter hard normals: ~13f |
| Parry / perfect block | 100-150ms | Cuphead parry freeze, Hades deflect |
| Boss mega-hit | 150-250ms | Dead Cells heavy weapons, Devil May Cry launchers |
| Kill / finishing blow | 200-400ms | Celeste dash kill: 3 freeze frames + slowdown |
| Electric effect | 1.5x base duration | Smash Bros formula: electric attacks multiply hitlag by 1.5 |

**Smash Bros Ultimate formula** (for reference — scales hitstop to damage): `floor((damage * 0.65 + 6) * hitlag_mult)` frames. A 15% damage move = 15 frames of hitlag. This scaling means stronger hits naturally feel heavier.

**Street Fighter 2 used 14 frames for ALL normals** regardless of strength — later games refined this into the light/medium/heavy scaling.

### The "Selective Freeze" Technique

Don't freeze the entire game. Freeze only the combatants. Particles, environment effects, and the camera continue running at normal speed. This prevents the freeze from feeling like a game stall — the world is alive, only the impact zone stops.

Implementation: use a `time_scale` property per-entity rather than `Engine.time_scale`.

### GDScript — Hitstop Manager

```gdscript
class_name HitstopManager
extends Node

## Centralized hitstop. Entities register themselves to be frozen.
## Non-registered entities (particles, environment) keep running.

var _frozen_nodes: Array[Node] = []
var _freeze_timer: float = 0.0
var _is_frozen: bool = false


func freeze(duration: float, nodes: Array[Node] = []) -> void:
	if duration <= _freeze_timer:
		return  # Don't override a longer freeze with a shorter one
	_freeze_timer = duration
	_frozen_nodes = nodes
	_is_frozen = true
	for node in _frozen_nodes:
		if node.has_method("set_frozen"):
			node.set_frozen(true)
		elif node is AnimatedSprite2D:
			node.speed_scale = 0.0
		node.set_physics_process(false)


func _process(delta: float) -> void:
	if not _is_frozen:
		return
	_freeze_timer -= delta
	if _freeze_timer <= 0.0:
		_unfreeze()


func _unfreeze() -> void:
	_is_frozen = false
	for node in _frozen_nodes:
		if node.has_method("set_frozen"):
			node.set_frozen(false)
		elif node is AnimatedSprite2D:
			node.speed_scale = 1.0
		node.set_physics_process(true)
	_frozen_nodes.clear()
```

### Hitstop by Weapon Weight (Dead Cells Pattern)

Dead Cells scales screenshake and hitstop to weapon class. Every weapon in the game has screen shake associated with it. Heavy weapons = long hitstop + big shake. Daggers = almost none.

| Weapon Class | Hitstop (ms) | Screenshake (px) | Feel |
|-------------|-------------|-----------------|------|
| Dagger / light | 30-40 | 1-2 | Crisp, rapid, staccato |
| Sword / medium | 50-70 | 3-5 | Solid, satisfying thwack |
| Hammer / heavy | 90-130 | 8-12 | Crushing, world-shaking |
| Whip / ranged | 20-30 | 1 | Snap, no weight |
| Explosion / AoE | 100-150 | 10-15 | Everything shudders |

---

## 3. Hit Flash (White Overlay Shader)

### What the Player Feels

The target blinks white for a split second — an unmistakable "that connected" signal. In dark environments, the flash is a lighthouse. In bright environments, the contrast still reads.

### Why White Beats Red

Red flash = "this thing is taking damage" (informational). White flash = "IMPACT" (visceral). White works because it's maximum contrast against any sprite color. Hades, Dead Cells, Hollow Knight all use white-first, colored-second.

### The Overbright Trap

`WHITE * 3.0` in HDR = blinding overbright halo. Godot's HDR pipeline will bloom the overexposed pixels. Cap flash color at `Color(2.0, 2.0, 2.0)` for HDR, or `Color(1.0, 1.0, 1.0)` for non-HDR. If using a `ColorRect` as fullscreen flash backdrop, set `color = Color(0, 0, 0, 0)` in `_ready()` — the default white + GPU hiccup = full white frame.

### Hit Flash Shader (Godot 4)

```gdshader
shader_type canvas_item;

uniform vec4 flash_color : source_color = vec4(1.0, 1.0, 1.0, 1.0);
uniform float flash_amount : hint_range(0.0, 1.0) = 0.0;

void fragment() {
	vec4 tex = texture(TEXTURE, UV);
	// Mix preserves alpha — invisible pixels stay invisible
	tex.rgb = mix(tex.rgb, flash_color.rgb, flash_amount);
	COLOR = tex;
}
```

**Why `mix()` instead of replacement**: a boolean on/off flash (the naive approach) produces a jarring pop. Tweening `flash_amount` from 1.0 to 0.0 over 3-4 frames gives a flash-then-fade that feels like residual energy dissipating.

### Flash Timing Table

| Effect | Flash In | Hold | Fade Out | Total | Notes |
|--------|----------|------|----------|-------|-------|
| Light hit | 0ms (instant) | 1 frame | 50ms | ~65ms | Barely registers consciously |
| Medium hit | 0ms | 2 frames | 80ms | ~115ms | The "standard" hit flash |
| Heavy hit | 0ms | 3 frames | 120ms | ~170ms | Lingers, sells the weight |
| Critical hit | 0ms | 4 frames | 150ms + secondary flash | ~220ms | Double flash: white then damage-type color |
| Death blow | 0ms | 6-8 frames | 200ms | ~330ms | Hold long enough to read as "final" |

### GDScript — Flash Controller

```gdscript
## Attach to any sprite with the hit flash shader material.
class_name HitFlash
extends Node

@export var target: CanvasItem  ## The sprite to flash
@export var default_color := Color.WHITE
@export var default_duration := 0.12

var _tween: Tween


func flash(color: Color = default_color, duration: float = default_duration) -> void:
	if _tween and _tween.is_running():
		_tween.kill()
	var mat: ShaderMaterial = target.material
	if not mat:
		return
	mat.set_shader_parameter("flash_color", color)
	mat.set_shader_parameter("flash_amount", 1.0)
	_tween = create_tween()
	# Hold for 2 frames (~33ms) then fade
	_tween.tween_interval(0.033)
	_tween.tween_property(mat, "shader_parameter/flash_amount", 0.0, duration)


func flash_critical(base_color: Color = Color.WHITE, type_color: Color = Color.RED) -> void:
	if _tween and _tween.is_running():
		_tween.kill()
	var mat: ShaderMaterial = target.material
	if not mat:
		return
	# First flash: white
	mat.set_shader_parameter("flash_color", base_color)
	mat.set_shader_parameter("flash_amount", 1.0)
	_tween = create_tween()
	_tween.tween_interval(0.05)
	_tween.tween_property(mat, "shader_parameter/flash_amount", 0.0, 0.06)
	# Second flash: damage type color
	_tween.tween_callback(func() -> void:
		mat.set_shader_parameter("flash_color", type_color)
		mat.set_shader_parameter("flash_amount", 0.8)
	)
	_tween.tween_property(mat, "shader_parameter/flash_amount", 0.0, 0.15)
```

---

## 4. Screenshake

### What the Player Feels

The world recoils from the impact. Light shakes = a flinch. Heavy shakes = the ground itself felt that.

### Directional vs Random

**Random shake** (offset by random x/y each frame): chaotic, explosive, "everything is happening." Good for explosions, AoE.

**Directional shake** (offset along the hit vector then decay): focused, impactful, "the force went THAT way." Good for melee hits, knockback.

**The Vlambeer school**: screenshake on EVERYTHING. Fire a gun = shake. Kill an enemy = shake. The camera communicates every action through motion. The principle is tiny changes that affect feel dramatically — bullet size, camera behavior, shake timing create entirely different experiences from the same mechanics.

### Shake Amplitude by Context

| Context | Amplitude (px) | Duration (ms) | Type |
|---------|---------------|---------------|------|
| Footstep (heavy char) | 0.5-1 | 50 | Vertical only |
| Light hit received | 2-3 | 80 | Directional |
| Medium hit received | 4-6 | 120 | Directional |
| Heavy hit received | 8-12 | 180 | Directional |
| Explosion nearby | 6-10 | 250 | Random |
| Boss stomp / ground pound | 10-15 | 300 | Vertical bias |
| Death blow | 12-18 | 350 | Directional + random |
| Screen-wide event (boss phase) | 5-8 | 500+ | Low-frequency random |

### Decay Curves

**Linear decay**: `amplitude * (1.0 - t/duration)`. Simple but feels robotic — the shake dies too evenly.

**Exponential decay**: `amplitude * exp(-t * decay_rate)`. Natural. Energy dissipation follows exponential decay in physics. `decay_rate` of 5.0-8.0 for snappy shakes, 2.0-3.0 for lingering rumbles.

**Sine with decay**: `amplitude * sin(t * frequency) * exp(-t * decay_rate)`. Oscillates like a struck object. Best for impacts where the camera should "ring" — boss stomps, explosions.

### Why Not Perlin Noise

Perlin noise produces smooth, organic movement — excellent for ambient camera sway (breathing, wind). Terrible for impact shakes. The smoothness kills the violence of the hit. A screen shake is violent and haphazard — the granularity from Perlin noise is lost. Use `randf_range()` with exponential decay for impacts. Use Perlin for ambient/continuous disturbance only.

### GDScript — Screenshake System

```gdscript
class_name ScreenShake
extends Camera2D

## Stacking screenshake with directional bias and exponential decay.

var _trauma: float = 0.0  ## 0.0 = calm, 1.0 = maximum shake
var _trauma_decay: float = 4.0  ## How fast trauma decays per second

@export var max_offset := Vector2(12.0, 8.0)  ## Maximum shake displacement
@export var max_rotation_deg := 2.0  ## Maximum rotation in degrees

## Directional bias: set when a hit connects to bias shake direction
var _direction_bias := Vector2.ZERO
var _direction_decay := 8.0

## Noise for ambient sway (NOT for impacts)
var _noise := FastNoiseLite.new()
var _noise_y: float = 0.0


func _ready() -> void:
	_noise.seed = randi()
	_noise.frequency = 0.5


func _process(delta: float) -> void:
	_trauma = maxf(_trauma - _trauma_decay * delta, 0.0)
	_direction_bias = _direction_bias.move_toward(Vector2.ZERO, _direction_decay * delta)

	if _trauma <= 0.001:
		offset = Vector2.ZERO
		rotation = 0.0
		return

	# Quadratic shake intensity — small trauma = barely noticeable,
	# high trauma = violent. This curve is why it feels natural.
	var shake_intensity: float = _trauma * _trauma
	_noise_y += delta * 50.0

	var shake_x: float = max_offset.x * shake_intensity * randf_range(-1.0, 1.0)
	var shake_y: float = max_offset.y * shake_intensity * randf_range(-1.0, 1.0)

	# Apply directional bias — the shake leans toward the hit direction
	shake_x += _direction_bias.x * shake_intensity * 0.5
	shake_y += _direction_bias.y * shake_intensity * 0.5

	offset = Vector2(shake_x, shake_y)
	rotation = deg_to_rad(max_rotation_deg * shake_intensity * randf_range(-1.0, 1.0))


## Add trauma (stacks up to 1.0). Higher = more violent.
func add_trauma(amount: float) -> void:
	_trauma = minf(_trauma + amount, 1.0)


## Directional shake: biases the shake toward a direction.
## hit_dir = normalized direction FROM attacker TO target.
func add_directional_trauma(amount: float, hit_dir: Vector2) -> void:
	_trauma = minf(_trauma + amount, 1.0)
	_direction_bias = hit_dir.normalized() * amount * max_offset.x


## Trauma amounts by context:
## Light hit:  0.15-0.25
## Medium hit: 0.30-0.45
## Heavy hit:  0.50-0.70
## Explosion:  0.60-0.80
## Boss slam:  0.80-1.00
```

---

## 5. Knockback

### What the Player Feels

The target is LAUNCHED. Light hits = a stagger, barely moves. Heavy hits = the target slides or flies across the screen. The deceleration curve determines whether the hit feels punchy (fast stop) or weighty (long slide).

### Directional vs Radial

**Directional knockback** (along the hit vector): precise, controlled, reads well. The player understands where the force came from. Used in Hollow Knight, Dead Cells.

**Radial knockback** (away from source center): simpler, good for explosions and AoE. Used for bomb blasts, shockwaves.

**Combined**: initial directional impulse + slight upward arc = the target pops up and away. This is the "action game" standard (Hades, Devil May Cry).

### Knockback Velocity Values

| Hit Type | Initial Velocity (px/s) | Decel Rate | Duration | Feel |
|----------|------------------------|-----------|----------|------|
| Light stagger | 150-250 | 0.85/frame | ~100ms | Flinch, barely moves |
| Medium knockback | 350-500 | 0.88/frame | ~200ms | Shoved back, recoverable |
| Heavy knockback | 600-900 | 0.90/frame | ~350ms | Launched, dramatic |
| Critical launch | 800-1200 | 0.92/frame | ~500ms | Sent flying |
| Boss slam | 400-600 + vertical 300 | 0.87/frame | ~400ms | Arc + slide |

### Deceleration Curves

**Multiplicative damping** (Dead Cells style): `velocity *= 0.88` each physics frame. Simple, feels natural. The 0.85-0.92 range covers most needs.

**Exponential approach**: `velocity = initial_vel * exp(-decay * time)`. Smoother, mathematically cleaner. `decay` of 6.0-10.0 for snappy stops.

**Friction-based**: `velocity = move_toward(velocity, 0, friction * delta)`. Linear decel feels like sliding on a surface. Good for ice/slippery floors.

**The Hades approach**: knockback velocity is HIGH but decays FAST. The target moves a lot in the first 2-3 frames, then stops quickly. This reads as "powerful hit, immediate recovery" — keeps the combat fast.

### Knockback + Hitstop Timing

The knockback velocity is SET during hitstop but not APPLIED until hitstop ends. This means: the moment freeze releases, the target instantly starts moving at full knockback speed. No ramp-up. The transition from frozen to flying is the snap that sells the impact.

### GDScript — Knockback Component

```gdscript
class_name KnockbackReceiver
extends Node

## Attach to any CharacterBody2D that can be knocked back.

@export var body: CharacterBody2D
@export var damping: float = 0.88  ## Per-physics-frame velocity multiplier

var knockback_velocity := Vector2.ZERO
var _is_active: bool = false


func apply(direction: Vector2, force: float) -> void:
	knockback_velocity = direction.normalized() * force
	_is_active = true


func apply_with_arc(direction: Vector2, force: float, vertical_force: float = -200.0) -> void:
	knockback_velocity = direction.normalized() * force
	knockback_velocity.y += vertical_force  # Negative = upward in Godot
	_is_active = true


func _physics_process(_delta: float) -> void:
	if not _is_active:
		return
	body.velocity += knockback_velocity
	knockback_velocity *= damping
	if knockback_velocity.length() < 5.0:
		knockback_velocity = Vector2.ZERO
		_is_active = false
```

---

## 6. Damage Numbers

### What the Player Feels

Numbers pop out of the target and float away. Small hits = small, quiet numbers. Big hits = large, dramatic, maybe a different color. Critical hits SCREAM — bigger, bolder, they demand attention.

### Pop Trajectory

The classic trajectory: spawn at impact point, launch upward + slight random horizontal offset, decelerate, fade out. Ragnarok Online refined this with a bounce — the number launches up, arcs down, bounces once smaller, then fades.

### Scale and Animation Values

| Hit Type | Font Size | Start Scale | Peak Scale | End Scale | Duration | Color |
|----------|-----------|-------------|-----------|-----------|----------|-------|
| Normal | Base | 0.5 | 1.0 | 0.8 | 0.6s | White |
| Heavy | Base * 1.3 | 0.6 | 1.2 | 0.9 | 0.8s | Yellow `#FFD700` |
| Critical | Base * 1.8 | 0.3 | 1.6 | 1.0 | 1.0s | Orange `#FF6600` + outline |
| Heal | Base | 0.5 | 1.0 | 0.8 | 0.8s | Green `#00E436` |
| Weak/resist | Base * 0.7 | 0.8 | 0.9 | 0.7 | 0.5s | Gray `#808080` |

### The "Don't Shrink to Zero" Rule

When numbers scale down during their fade, they should never shrink all the way to nothing. Stop at 60-70% of peak scale, then alpha-fade the rest. Numbers that shrink to a point look like they're being sucked into a void. Numbers that hold size but fade out look like they're dissipating — more natural.

### Critical Hit Emphasis

Size beats color for communicating crits. In chaotic combat with multiple damage types, color gets lost. A number that is 1.5-1.8x larger than normal is instantly readable regardless of palette.

The double-punch pattern: the number spawns at 0.3x scale, snaps to 1.6x in 2 frames (overshoot), then settles to 1.2x (ease-out bounce). The snap is what reads as "critical."

### GDScript — Damage Number

```gdscript
class_name DamageNumber
extends Node2D

@onready var label: Label = $Label

var _velocity := Vector2.ZERO
var _gravity := 300.0
var _lifetime := 0.0
var _max_lifetime := 0.8


func setup(value: int, pos: Vector2, is_crit: bool = false) -> void:
	global_position = pos
	label.text = str(value)

	# Random horizontal spread
	var spread: float = randf_range(-40.0, 40.0)
	_velocity = Vector2(spread, randf_range(-180.0, -140.0))

	if is_crit:
		_max_lifetime = 1.0
		label.add_theme_font_size_override("font_size", 24)
		label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.0))
		label.add_theme_color_override("font_outline_color", Color(0.2, 0.0, 0.0))
		label.add_theme_constant_override("outline_size", 3)
		# Overshoot snap: start small, scale up fast, settle
		scale = Vector2(0.3, 0.3)
		var tween := create_tween()
		tween.tween_property(self, "scale", Vector2(1.6, 1.6), 0.04)
		tween.tween_property(self, "scale", Vector2(1.2, 1.2), 0.12).set_ease(Tween.EASE_OUT).set_trans(Tween.TRANS_BACK)
	else:
		label.add_theme_font_size_override("font_size", 16)
		label.add_theme_color_override("font_color", Color.WHITE)
		scale = Vector2(0.5, 0.5)
		var tween := create_tween()
		tween.tween_property(self, "scale", Vector2(1.0, 1.0), 0.08).set_ease(Tween.EASE_OUT)


func _process(delta: float) -> void:
	_lifetime += delta
	_velocity.y += _gravity * delta
	position += _velocity * delta

	# Fade out in the last 30% of lifetime
	var life_ratio: float = _lifetime / _max_lifetime
	if life_ratio > 0.7:
		var fade: float = 1.0 - (life_ratio - 0.7) / 0.3
		modulate.a = fade

	if _lifetime >= _max_lifetime:
		queue_free()
```

---

## 7. The Complete Pipeline

### GDScript — Hit Feedback Orchestrator

This ties everything together. Call `execute()` when a hit connects.

```gdscript
class_name HitFeedback
extends Node

## Orchestrates the complete hit feedback pipeline.
## Expects these siblings/children in the scene:
## - HitstopManager (autoload or scene singleton)
## - ScreenShake (on the Camera2D)
## - HitFlash (on the target)
## - KnockbackReceiver (on the target)

## Hit weight presets
enum Weight { LIGHT, MEDIUM, HEAVY, CRITICAL }

const PRESETS := {
	Weight.LIGHT: {
		"hitstop": 0.04,
		"flash_duration": 0.08,
		"trauma": 0.2,
		"knockback_force": 200.0,
		"damping": 0.85,
		"shake_type": "directional",
	},
	Weight.MEDIUM: {
		"hitstop": 0.065,
		"flash_duration": 0.12,
		"trauma": 0.35,
		"knockback_force": 450.0,
		"damping": 0.88,
		"shake_type": "directional",
	},
	Weight.HEAVY: {
		"hitstop": 0.1,
		"flash_duration": 0.15,
		"trauma": 0.6,
		"knockback_force": 750.0,
		"damping": 0.90,
		"shake_type": "directional",
	},
	Weight.CRITICAL: {
		"hitstop": 0.15,
		"flash_duration": 0.18,
		"trauma": 0.8,
		"knockback_force": 1000.0,
		"damping": 0.91,
		"shake_type": "directional",
	},
}


func execute(
	attacker: Node2D,
	target: Node2D,
	weight: Weight,
	damage: int = 0,
	hit_position: Vector2 = Vector2.ZERO,
) -> void:
	var preset: Dictionary = PRESETS[weight]
	var hit_dir: Vector2 = (target.global_position - attacker.global_position).normalized()
	if hit_position == Vector2.ZERO:
		hit_position = (attacker.global_position + target.global_position) * 0.5

	# --- FRAME 0: Simultaneous ---

	# 1. Hitstop
	var hitstop: HitstopManager = _get_hitstop()
	if hitstop:
		hitstop.freeze(preset.hitstop, [attacker, target])

	# 2. Hit flash (starts immediately, fades after hitstop)
	var flash: HitFlash = target.get_node_or_null("HitFlash")
	if flash:
		if weight == Weight.CRITICAL:
			flash.flash_critical()
		else:
			flash.flash(Color.WHITE, preset.flash_duration)

	# 3. Impact particles (spawn now, they animate during hitstop)
	_spawn_particles(hit_position, hit_dir, weight)

	# 4. SFX
	_play_hit_sfx(weight)

	# 5. Damage number (spawns now, pop animation plays during hitstop)
	if damage > 0:
		_spawn_damage_number(hit_position, damage, weight == Weight.CRITICAL)

	# --- AFTER HITSTOP: Deferred effects ---
	# These fire when hitstop ends via a timer matching hitstop duration

	get_tree().create_timer(preset.hitstop, false, true).timeout.connect(
		func() -> void:
			# 6. Knockback
			var kb: KnockbackReceiver = target.get_node_or_null("KnockbackReceiver")
			if kb:
				kb.damping = preset.damping
				kb.apply(hit_dir, preset.knockback_force)

			# 7. Screenshake (directional, away from hit)
			var camera := _get_shake_camera()
			if camera:
				camera.add_directional_trauma(preset.trauma, hit_dir)
	)


func _get_hitstop() -> HitstopManager:
	return get_node_or_null("/root/HitstopManager")


func _get_shake_camera() -> ScreenShake:
	var vp := get_viewport()
	if vp and vp.get_camera_2d() is ScreenShake:
		return vp.get_camera_2d() as ScreenShake
	return null


func _spawn_particles(_pos: Vector2, _dir: Vector2, _weight: Weight) -> void:
	# Project-specific: instantiate your hit particle scene here.
	# Burst count: LIGHT=4, MEDIUM=8, HEAVY=16, CRITICAL=24
	# Particle initial velocity should follow _dir (impact direction)
	pass


func _play_hit_sfx(_weight: Weight) -> void:
	# Project-specific: play hit sound with pitch variation.
	# Pitch range: 0.9-1.1 for variety. Lower pitch = heavier hit.
	pass


func _spawn_damage_number(pos: Vector2, value: int, is_crit: bool) -> void:
	# Project-specific: instantiate DamageNumber scene.
	# Example:
	# var num: DamageNumber = DAMAGE_NUMBER_SCENE.instantiate()
	# get_tree().current_scene.add_child(num)
	# num.setup(value, pos, is_crit)
	pass
```

---

## 8. Advanced Techniques

### Impact Frame (Full-Screen White Flash)

For the most devastating hits — boss kills, finishing blows, phase transitions — a 1-2 frame full-screen white flash. The entire viewport goes white for 16-33ms, then snaps back. This is the "photographer's flash" — it burns the moment into the player's retina.

Implementation: a `ColorRect` covering the viewport, normally transparent. On impact, set `modulate.a = 0.6-0.8` for 1-2 frames, then tween to 0.0.

**Caution**: never use `modulate.a = 1.0` (fully opaque white) — the player will think the game crashed. 0.6-0.8 reads as "flash" not "error."

### Chromatic Aberration on Heavy Hits

Split the RGB channels by 2-4 pixels on critical hits. Decays over 200-300ms. Subtle but sells "the world distorted from the force." See `recipes/shader_cookbook_2d.md` for the shader.

### Time Scale Dip (Slow Motion)

After a kill or finishing blow: `Engine.time_scale = 0.3` for 200-400ms, then ramp back to 1.0 over 300ms. Different from hitstop — the game still runs, just slowly. This gives the player a moment to appreciate the kill.

**Warning**: `Engine.time_scale` below 0.15 can block GolemCapture screenshots. Minimum safe value: 0.15.

### Sprite Shake During Hitstop

During the freeze, the damaged sprite vibrates 1-2 pixels left/right each frame. Borrowed from Street Fighter 2 and Final Fight. The character is "frozen" but trembling from the impact. This sells pain without breaking the freeze.

```gdscript
## Call during hitstop on the damaged sprite
func sprite_vibrate(sprite: Node2D, intensity: float = 1.5) -> void:
	var base_pos: Vector2 = sprite.position
	var tween := create_tween().set_loops()
	tween.tween_property(sprite, "position:x", base_pos.x + intensity, 0.016)
	tween.tween_property(sprite, "position:x", base_pos.x - intensity, 0.016)
	# Store the tween to kill it when hitstop ends
	sprite.set_meta("vibrate_tween", tween)
	sprite.set_meta("vibrate_base_pos", base_pos)


func stop_vibrate(sprite: Node2D) -> void:
	var tween: Tween = sprite.get_meta("vibrate_tween", null)
	if tween:
		tween.kill()
	var base_pos: Vector2 = sprite.get_meta("vibrate_base_pos", sprite.position)
	sprite.position = base_pos
	sprite.remove_meta("vibrate_tween")
	sprite.remove_meta("vibrate_base_pos")
```

---

## Gotchas

1. **Hitstop + `_physics_process` = velocity accumulates** — if gravity runs during hitstop, the character drops like a stone when the freeze ends. Always disable `_physics_process` on frozen entities, not just `_process`

2. **Screenshake + pixel art = sub-pixel jitter** — fractional pixel offsets create shimmering sprites. Round `Camera2D.offset` to integer values, or use `snap_2d_transforms_to_pixel` in Project Settings

3. **Multiple simultaneous hits = effect stacking** — two enemies hitting the same frame means double hitstop, double flash, double shake. Cap each system: hitstop takes the LONGEST duration (don't add), trauma caps at 1.0, flash is boolean (on/off not stacked)

4. **Knockback during physics flush = crash** — if a signal fires during physics (e.g., Area2D `body_entered` triggers knockback that adds a child), you get "Can't change this state while flushing queries." Use `call_deferred` for `add_child` operations, but apply knockback velocity directly (velocity assignment is safe during physics)

5. **Flash shader on AnimatedSprite2D with shared material** — if multiple sprites share the same `ShaderMaterial` resource, flashing one flashes ALL of them. Always use `material.resource_local_to_scene = true` or create unique materials per instance

6. **Engine.time_scale affects tween durations** — a hitstop tween set to 0.1s will take 0.33s if `time_scale` is 0.3. Use `SceneTreeTimer` with `process_always = true` for hitstop timers that must be time_scale-independent

7. **Damage numbers pile up in bullet hell** — 50 enemies hit by AoE = 50 overlapping numbers = unreadable. Cap simultaneous damage numbers (8-12 max) or batch AoE damage into a single "total" number after a short delay

8. **Camera2D offset during screenshake ignores camera limits** — the shake can push the viewport beyond `limit_left`/`limit_top`. Clamp the final offset: `offset.x = clampf(offset.x, -max_offset.x, max_offset.x)`. Or accept it — a brief limit break during intense hits can feel intentional

9. **Hit feedback on invincible targets feels wrong** — if the player hits an invincible enemy and gets full feedback (flash, shake, particles), they think they dealt damage. Use a different palette for "blocked" hits: blue flash instead of white, metallic SFX, reduced screenshake, no knockback, no damage number

10. **The "frequency vs rarity" rule** — frequent events (basic attack hits) need SUBTLE feedback. Rare events (critical hits, boss kills) get DRAMATIC feedback. If every hit gets the full pipeline at max intensity, nothing feels special. Reserve the heavy effects for moments that matter
