# Godot 4 — Particle Presets (2D)

> **Purpose**: Tested GPUParticles2D configurations for common 2D game effects. Values, layering patterns, and feel-design rules — not API docs.

---

## The Feel Rules

Before any preset: what separates "juicy" particles from flat ones.

### Timing is everything
- **Frequent events** (hit, collect, footstep) = subtle, short-lived (0.1-0.4s lifetime)
- **Rare events** (level up, death, boss intro) = dramatic, longer (0.5-1.5s lifetime)
- Reduce time on screen. Overstaying particles = visual clutter. Cut lifetime 20% shorter than your first instinct.

### Scale-over-lifetime: the golden curve
- **Burst effects** (explosion, dust puff): start at full scale, shrink to 0 with an ease-out curve. Fast at start, lingers at the end.
- **Trail effects** (magic, dash): start small, grow slightly, then shrink. Bell curve, peak around 30% lifetime.
- **Ambient effects** (motes, snow): near-constant scale with tiny oscillation. Boring is correct — they're atmosphere, not events.

### Color-over-lifetime: three-stop minimum
- **Stop 1 (0%)**: bright, saturated, high alpha — the "punch"
- **Stop 2 (40-60%)**: slightly desaturated, medium alpha — the "body"
- **Stop 3 (100%)**: fully transparent, darker hue — the "fade"
- Never fade to full black. Fade to a darker shade of the base hue (orange fire fades to dark red, not gray).

### Layering = the real secret
Single-emitter effects look flat. Shipped games layer 2-3 GPUParticles2D nodes per effect:
- **Layer 1 (core)**: fewer particles, larger, brighter — the readable shape
- **Layer 2 (detail)**: more particles, smaller, faster, semi-transparent — the texture
- **Layer 3 (glow)**: 3-5 particles, largest, additive blend, very low alpha — the light bleed

### The Hades approach
Supergiant's Hades effects use: consistent color values across all emitters in one effect (same purple across drip/flame/spark = seamless blend), hard color transitions rather than smooth gradients (gradient maps with 3-4 stops, minimal blend), and masked/flat shading for readable silhouettes even in chaos.

---

## Preset 1 — Dust Puff (Landing / Wall Hit)

> **Feel**: weight, impact, grounding. The player FEELS the floor exist.

### GPUParticles2D node
| Property | Value | Why |
|---|---|---|
| `amount` | 6-8 | Few particles, each visible. Not a cloud — discrete chunks |
| `lifetime` | 0.3 | Gone fast. Dust doesn't linger |
| `one_shot` | true | Single burst on event |
| `explosiveness` | 1.0 | All particles spawn at once |
| `preprocess` | 0.0 | Burst starts from zero |

### ParticleProcessMaterial
| Property | Value | Why |
|---|---|---|
| `direction` | (0, -1, 0) | Upward from ground |
| `spread` | 60 | Cone shape, not hemisphere |
| `initial_velocity_min` | 30 | Some go slow |
| `initial_velocity_max` | 80 | Some go fast — velocity variance = organic |
| `gravity` | (0, 200, 0) | Pulls back down quickly — arc, not float |
| `damping_min` | 8 | Slows down mid-flight |
| `damping_max` | 15 | Variance in deceleration |
| `scale_min` | 0.4 | |
| `scale_max` | 1.0 | Size variance = organic |
| `emission_shape` | POINT | All from one spot |

### Scale curve
Starts at 1.0, ease-out to 0.0 at lifetime end. The particle shrinks as it dies.

### Color ramp
| Position | Color | Alpha |
|---|---|---|
| 0.0 | White `#FFFFFF` | 0.8 |
| 0.3 | Warm gray `#C8BEB4` | 0.6 |
| 1.0 | Brown-gray `#8C7B6B` | 0.0 |

### Texture
4x4 or 8x8 soft circle PNG. White, alpha-feathered edges. For pixel art: 2x2 solid white square, no feathering.

### Trigger pattern (GDScript)
```gdscript
func _on_landed():
    $DustPuff.restart()  # restart() resets and emits one_shot
```

### Feel adjustment
- Too floaty? Increase gravity to 300, decrease lifetime to 0.2
- Too heavy? Decrease gravity to 100, increase spread to 90
- Celeste-style: 3-4 single-pixel particles, lifetime 0.15, high velocity (120), high gravity (400). Ultra-snappy.

---

## Preset 2 — Explosion / Impact Burst

> **Feel**: violence, power, screen-shake territory. Paired with freeze frame for maximum impact.

### Layer 1 — Core blast (bright flash)
| Property | Value |
|---|---|
| `amount` | 12-16 |
| `lifetime` | 0.4 |
| `one_shot` | true |
| `explosiveness` | 1.0 |

| Material Property | Value |
|---|---|
| `direction` | (0, 0, 0) |
| `spread` | 180 |
| `initial_velocity_min` | 80 |
| `initial_velocity_max` | 200 |
| `gravity` | (0, 0, 0) |
| `damping_min` | 5 |
| `damping_max` | 12 |
| `scale_min` | 0.6 |
| `scale_max` | 1.5 |
| `emission_shape` | SPHERE, radius 3 |

**Color ramp**: White `#FFFFFF` (0.0) > Yellow `#FFD54F` (0.2) > Orange `#FF6B00` (0.5) > Transparent dark red `#4A0000` (1.0)

**Scale curve**: 1.0 > 0.3 (ease-out). Fast shrink.

### Layer 2 — Debris / sparks
| Property | Value |
|---|---|
| `amount` | 20-30 |
| `lifetime` | 0.6 |
| `one_shot` | true |
| `explosiveness` | 0.9 |

| Material Property | Value |
|---|---|
| `spread` | 180 |
| `initial_velocity_min` | 100 |
| `initial_velocity_max` | 300 |
| `gravity` | (0, 300, 0) |
| `damping_min` | 2 |
| `damping_max` | 6 |
| `scale_min` | 0.2 |
| `scale_max` | 0.5 |

**Color ramp**: Yellow `#FFD54F` (0.0) > Orange `#E65100` (0.5) > Transparent `#3E0000` (1.0)

**Scale curve**: 0.8 > 0.0 (linear). Sparks just vanish.

### Layer 3 — Glow halo
| Property | Value |
|---|---|
| `amount` | 1-3 |
| `lifetime` | 0.25 |
| `one_shot` | true |
| `explosiveness` | 1.0 |

| Material Property | Value |
|---|---|
| `spread` | 0 |
| `initial_velocity_min` | 0 |
| `initial_velocity_max` | 0 |
| `gravity` | (0, 0, 0) |
| `scale_min` | 2.0 |
| `scale_max` | 3.0 |

**Material (CanvasItemMaterial)**: `blend_mode = Add`

**Color ramp**: Orange `#FF9800` alpha 0.5 (0.0) > Transparent (1.0)

**Scale curve**: 1.0 > 2.0 (expand), alpha drops simultaneously. Grows while fading = light bloom feel.

**Texture**: 32x32 soft radial gradient, white center fading to transparent.

### Dead Cells reference
Dead Cells blood splatter uses: high particle count (30+), low bounciness (0.1), small drag for smear on surfaces, lifetime randomness near 1.0 (max variance). The violence reads because sparks have gravity + floor collision while the core flash has zero gravity.

---

## Preset 3 — Magic Trail / Dash Trail

> **Feel**: speed, elegance, the player is fast and powerful.

### GPUParticles2D node
| Property | Value | Why |
|---|---|---|
| `amount` | 20-30 | Dense trail |
| `lifetime` | 0.5-0.8 | Long enough to form visible trail |
| `one_shot` | false | Continuous while moving |
| `explosiveness` | 0.0 | Steady stream, not bursts |
| `local_coords` | false | Particles stay in world space = trail forms behind moving emitter |

### ParticleProcessMaterial
| Property | Value |
|---|---|
| `direction` | (0, 0, 0) |
| `spread` | 15 |
| `initial_velocity_min` | 0 |
| `initial_velocity_max` | 10 |
| `gravity` | (0, 0, 0) or (0, -20, 0) for slight float-up |
| `damping_min` | 0 |
| `damping_max` | 0 |
| `scale_min` | 0.5 |
| `scale_max` | 1.0 |

### Color ramp (cyan magic example)
| Position | Color | Alpha |
|---|---|---|
| 0.0 | White `#FFFFFF` | 1.0 |
| 0.15 | Bright cyan `#00E5FF` | 0.9 |
| 0.6 | Deep blue `#1A237E` | 0.4 |
| 1.0 | Dark purple `#0D0030` | 0.0 |

### Scale curve
Bell curve: starts at 0.3, peaks at 1.0 around 20% lifetime, back to 0.0 at end. Each particle fades in then out.

### CanvasItemMaterial
`blend_mode = Add` — essential for trails. Additive blending makes overlapping particles glow brighter where the trail is dense.

### Glow integration (Godot 4.4+)
1. Project Settings > Rendering > Viewport > HDR 2D = **ON**
2. WorldEnvironment > Background Mode = **Canvas**
3. WorldEnvironment > Glow > Enabled = **ON**, threshold < 1.0 (try 0.6)
4. Set particle modulate color to values > 1.0 (e.g. `Color(1.3, 1.3, 1.3)`) to trigger HDR bloom
5. Glow blend mode = **Additive** (best for particles)

### Hades dash reference
Hades dash trail = player silhouettes + particle sparkles. The silhouette is NOT particles — it's stamped sprites with fade tween. The sparkles around it are small additive particles with short lifetime (0.2-0.3s), high emission rate, minimal velocity.

---

## Preset 4 — Fire / Torch

> **Feel**: warmth, danger, life. Fire should flicker, not pulse uniformly.

### Layer 1 — Flame core
| Property | Value |
|---|---|
| `amount` | 15-20 |
| `lifetime` | 0.6-0.8 |
| `explosiveness` | 0.0 |

| Material Property | Value |
|---|---|
| `direction` | (0, -1, 0) |
| `spread` | 25 |
| `initial_velocity_min` | 40 |
| `initial_velocity_max` | 80 |
| `gravity` | (0, -30, 0) |
| `damping_min` | 3 |
| `damping_max` | 6 |
| `scale_min` | 0.6 |
| `scale_max` | 1.2 |
| `lifetime_randomness` | 0.3 |

**Color ramp**: White `#FFFFFF` (0.0) > Yellow `#FFE082` (0.15) > Orange `#FF6D00` (0.45) > Dark red `#4A0000` alpha 0 (1.0)

**Scale curve**: 1.0 at start, ease-out to 0.2 at end. Fire shrinks as it rises.

### Layer 2 — Embers / sparks
| Property | Value |
|---|---|
| `amount` | 5-8 |
| `lifetime` | 1.0-1.5 |

| Material Property | Value |
|---|---|
| `direction` | (0, -1, 0) |
| `spread` | 40 |
| `initial_velocity_min` | 20 |
| `initial_velocity_max` | 60 |
| `gravity` | (0, -15, 0) |
| `scale_min` | 0.1 |
| `scale_max` | 0.3 |
| `lifetime_randomness` | 0.5 |

**Color ramp**: Orange `#FF9800` alpha 1.0 (0.0) > Red-orange `#E65100` alpha 0.6 (0.5) > Transparent (1.0)

Tiny texture (2x2 or 4x4 pixel). Embers are points, not blobs.

### Layer 3 — Light glow (optional)
Not a particle layer — use a PointLight2D child with animated energy via AnimationPlayer or tween:
```gdscript
func _ready():
    var tween := create_tween().set_loops()
    tween.tween_property($PointLight2D, "energy", 0.8, 0.3).set_trans(Tween.TRANS_SINE)
    tween.tween_property($PointLight2D, "energy", 1.2, 0.3).set_trans(Tween.TRANS_SINE)
```

### Feel adjustment
- `lifetime_randomness` is the key to organic fire. 0.0 = mechanical pulse, 0.3+ = living flame.
- Turbulence (if enabled) with `noise_strength` 0.5-1.5 and `noise_scale` 0.5-1.0 adds wobble. But test carefully — turbulence + box emission shape has a known bug where particles cluster at edges.
- For pixel art fire: reduce amount to 8-10, use 2x2 texture, increase velocity variance. Each "flame pixel" should be individually readable.

---

## Preset 5 — Collectible Sparkle / Pickup Glow

> **Feel**: reward, anticipation, "pick me up". Idle animation that draws the eye.

### GPUParticles2D node
| Property | Value |
|---|---|
| `amount` | 6-10 |
| `lifetime` | 0.8-1.2 |
| `explosiveness` | 0.0 |

### ParticleProcessMaterial
| Property | Value |
|---|---|
| `direction` | (0, -1, 0) |
| `spread` | 180 |
| `initial_velocity_min` | 10 |
| `initial_velocity_max` | 30 |
| `gravity` | (0, -10, 0) |
| `damping_min` | 2 |
| `damping_max` | 5 |
| `scale_min` | 0.2 |
| `scale_max` | 0.6 |
| `emission_shape` | SPHERE, radius 8 |
| `lifetime_randomness` | 0.4 |

### Color ramp (gold coin example)
| Position | Color | Alpha |
|---|---|---|
| 0.0 | White `#FFFFFF` | 1.0 |
| 0.2 | Gold `#FFD700` | 0.8 |
| 0.7 | Amber `#FF8F00` | 0.3 |
| 1.0 | Transparent | 0.0 |

### Scale curve
Starts at 0.0, ramps to 1.0 at 15% lifetime, holds until 60%, then ease-out to 0.0. Pop-in effect = sparkle.

### CanvasItemMaterial
`blend_mode = Add` for glow-on-dark-backgrounds. Switch to `Mix` if the scene is already bright.

### Pickup burst (on collect)
Separate one_shot GPUParticles2D, `amount` 12-15, `explosiveness` 1.0, `lifetime` 0.3, `spread` 180, `velocity` 60-120. Same color scheme but brighter. Trigger with `$PickupBurst.restart()`.

---

## Preset 6 — Environmental Ambiance

### 6a — Rain

> **Feel**: mood, weight of the world, isolation or cleansing.

| Property | Value |
|---|---|
| `amount` | 500-1000 |
| `lifetime` | 0.7 |
| `preprocess` | 0.5 |

| Material Property | Value |
|---|---|
| `direction` | (0, 1, 0) |
| `spread` | 5-10 |
| `initial_velocity_min` | 400 |
| `initial_velocity_max` | 500 |
| `gravity` | (0, 3000, 0) |
| `emission_shape` | BOX |
| `emission_box_extents` | (viewport_width/2, 1, 0) |
| `scale_min` | 0.2 |
| `scale_max` | 1.0 |
| `lifetime_randomness` | 0.22 |

**Texture**: 1x4 or 2x8 stretched white rectangle. Rain is vertical streaks, not dots.

**Z-index**: 1200+ (above most game elements, below UI).

**Splash layer**: Separate GPUParticles2D, `emitting = false`, triggered per-frame at ground level. `amount` 200, `lifetime` 0.2, `lifetime_randomness` 1.0, `gravity` (0, 500, 0). This sells the rain hitting something.

### 6b — Snow

> **Feel**: calm, gentle, passage of time.

| Property | Value |
|---|---|
| `amount` | 100-200 |
| `lifetime` | 4.0-6.0 |
| `preprocess` | 3.0 |

| Material Property | Value |
|---|---|
| `direction` | (0, 1, 0) |
| `spread` | 20-30 |
| `initial_velocity_min` | 15 |
| `initial_velocity_max` | 30 |
| `gravity` | (0, 20, 0) |
| `emission_shape` | BOX |
| `emission_box_extents` | (viewport_width/2, 1, 0) |
| `scale_min` | 0.3 |
| `scale_max` | 1.0 |
| `lifetime_randomness` | 0.3 |
| `damping_min` | 1 |
| `damping_max` | 3 |

**Turbulence** (if used): `noise_strength` 0.5, `noise_scale` 1.5, `influence_min` 0.3, `influence_max` 0.7. Creates gentle drift. WARNING: turbulence + box emission can cluster particles at box edges (Godot bug #74541). Test thoroughly.

**Texture**: 4x4 soft white circle for regular snow. 2x2 solid white for pixel art.

**Depth trick**: Two GPUParticles2D layers — foreground (larger, faster, higher alpha) and background (smaller, slower, lower alpha, slightly gray). Parallax without parallax nodes.

### 6c — Floating Motes / Dust

> **Feel**: a space is alive, magical, ancient, or abandoned.

| Property | Value |
|---|---|
| `amount` | 15-25 |
| `lifetime` | 3.0-5.0 |
| `preprocess` | 2.0 |

| Material Property | Value |
|---|---|
| `direction` | (0, -1, 0) |
| `spread` | 180 |
| `initial_velocity_min` | 3 |
| `initial_velocity_max` | 8 |
| `gravity` | (0, -2, 0) |
| `emission_shape` | BOX |
| `emission_box_extents` | (camera_width/2, camera_height/2, 0) |
| `scale_min` | 0.3 |
| `scale_max` | 0.8 |
| `lifetime_randomness` | 0.5 |
| `damping_min` | 1 |
| `damping_max` | 2 |

**Color ramp**: `#FFFFFF` alpha 0.0 (0.0) > `#FFF8E1` alpha 0.4 (0.2) > `#FFF8E1` alpha 0.4 (0.8) > transparent (1.0). Fade in, hold, fade out.

**Scale curve**: Slight sine-like oscillation if possible, otherwise flat. Motes don't change size dramatically.

For magical motes: add additive blend + HDR color (modulate > 1.0) to trigger glow.

---

## Preset 7 — Blood Splatter / Hit Particles

> **Feel**: violence, consequence, the attack CONNECTED.

### GPUParticles2D node
| Property | Value |
|---|---|
| `amount` | 15-25 |
| `lifetime` | 0.4 |
| `one_shot` | true |
| `explosiveness` | 0.95 |

### ParticleProcessMaterial
| Property | Value |
|---|---|
| `direction` | based on hit normal |
| `spread` | 45-60 |
| `initial_velocity_min` | 80 |
| `initial_velocity_max` | 250 |
| `gravity` | (0, 400, 0) |
| `damping_min` | 3 |
| `damping_max` | 8 |
| `scale_min` | 0.2 |
| `scale_max` | 0.8 |
| `lifetime_randomness` | 0.4 |

### Color ramp
| Position | Color | Alpha |
|---|---|---|
| 0.0 | Bright red `#FF1744` | 1.0 |
| 0.3 | Dark red `#B71C1C` | 0.9 |
| 0.7 | Very dark red `#4A0000` | 0.5 |
| 1.0 | Black-red `#1A0000` | 0.0 |

### Direction from hit normal
```gdscript
func spawn_blood(hit_pos: Vector2, hit_dir: Vector2) -> void:
    var blood := blood_scene.instantiate()
    blood.global_position = hit_pos
    # Rotate to emit away from the hit direction
    blood.process_material.direction = Vector3(hit_dir.x, hit_dir.y, 0)
    get_tree().current_scene.add_child(blood)
    blood.restart()
```

### Dead Cells reference
Dead Cells blood: high velocity variance (100-300), gravity pulls droplets into arcs, low bounciness on any collision (0.1), slight drag for smear feeling. The blood reads as violent because droplets have individual arcs (gravity + velocity variance), not a uniform spray.

---

## Performance & Gotchas

### CPU vs GPU: when to pick which
| Scenario | Use |
|---|---|
| < 20 particles, burst, infrequent | CPUParticles2D (lower base cost) |
| > 50 particles, continuous | GPUParticles2D (scales better) |
| Needs physics collision | CPUParticles2D (only option) |
| Many simultaneous emitters (rain + dust + fire) | GPUParticles2D |
| Targeting low-end / no GPU accel | CPUParticles2D |

**The benchmark reality**: GPU particles have a HIGH base cost in Godot 4. At 5 particles, CPU gets 35 FPS vs GPU at 13 FPS in stress tests. But at 500 particles, GPU holds 13 FPS while CPU drops to 7. The crossover point is somewhere around 50-100 particles per emitter.

### Performance budget (2D)
- **Comfortable**: < 500 total active particles across all emitters on screen
- **Caution zone**: 500-2000 total particles
- **Profile required**: 2000+ particles
- Each GPUParticles2D node = 1 draw call minimum, regardless of particle count
- Additive blend particles = separate draw call from normal particles

### Visibility rect
**Always set it.** Use Particles > Generate Visibility Rect in the editor toolbar. Without it, particles render even when the emitter is off-screen. For long-distance effects (rain), manually expand the rect.

### Preprocess
Set to > 0 for any always-on emitter (ambient motes, rain, torch). Without preprocess, the effect "ramps up" when the scene loads — visible pop-in. Match preprocess to lifetime for a fully populated system on first frame.

### Fixed FPS
Setting `fixed_fps` to a value > 0 renders particles at that framerate. Useful for pixel art (set to 12-15 for retro feel) or performance savings on ambient effects. Does NOT affect simulation speed, only visual update rate.

### Godot 4 specific gotchas
- **GPUParticles2D without texture = 1x1 white squares.** Assign a texture or accept pixel-dots.
- **`draw_pass` (3D concept) does not apply to 2D.** In 2D, use the `texture` property directly.
- **Turbulence + box emission = particle clustering at edges** (issue #74541). Test turbulence with sphere emission instead, or disable `Disable Z` flag in particle flags.
- **Sub-emitters work in 2D** but the sub-emitter GPUParticles2D stops self-emitting. It only fires when triggered by the parent.
- **2D particle collision** only works with LightOccluder2D, NOT PhysicsBody2D. For floor-collision blood, fake it with gravity + lifetime.
- **Trails in 2D**: set `trail_enabled = true`, `trail_lifetime` to desired length, `amount` to power-of-2 (16, 32, 64). Trail lifetime = amount for proper step count. Known to be finicky — particle-based trails via shader (see godotshaders.com) are more reliable.
- **Color ramp with GradientTexture1D** can silently fail in some Godot 4 versions (issue #61618). If colors aren't applying, try recreating the gradient resource.
- **`amount_ratio`** does NOT improve performance. Resources are allocated for total `amount` regardless. Change `amount` itself.
- **Additive blend artifacts**: known issue in Godot 4 (issue #74785). Semi-transparent additive particles can show edge artifacts. Workaround: use fully opaque textures with alpha in color_ramp instead.

### Glow setup (Godot 4.4+)
1. Project Settings > Rendering > Viewport > HDR 2D = ON
2. Add WorldEnvironment, Background Mode = Canvas
3. Environment > Glow > Enabled, Threshold < 1.0 (0.5-0.8 for 2D)
4. On particle node: modulate color with channel values > 1.0 (try 1.3)
5. CanvasItemMaterial blend_mode = Add
6. Glow blend mode = Additive (in Environment)

Before 4.4: 2D renders in LDR, glow doesn't trigger from modulate values. Workaround: use additive sprites to fake glow manually (see `lights_and_shadows.md`).

---

## Quick Reference Table

| Effect | Amount | Lifetime | Explosiveness | Key feel lever |
|---|---|---|---|---|
| Dust puff | 6-8 | 0.3 | 1.0 | Gravity (weight) |
| Explosion core | 12-16 | 0.4 | 1.0 | Scale curve (shrink speed) |
| Explosion sparks | 20-30 | 0.6 | 0.9 | Velocity variance (chaos) |
| Magic trail | 20-30 | 0.5-0.8 | 0.0 | local_coords OFF + blend Add |
| Fire core | 15-20 | 0.6-0.8 | 0.0 | lifetime_randomness (organic) |
| Fire embers | 5-8 | 1.0-1.5 | 0.0 | Tiny scale + long life |
| Collectible sparkle | 6-10 | 0.8-1.2 | 0.0 | Scale pop-in curve |
| Rain | 500-1000 | 0.7 | 0.0 | Preprocess + splash layer |
| Snow | 100-200 | 4.0-6.0 | 0.0 | Turbulence drift + depth layers |
| Floating motes | 15-25 | 3.0-5.0 | 0.0 | Fade-in/fade-out alpha ramp |
| Blood splatter | 15-25 | 0.4 | 0.95 | Hit direction + gravity arcs |
| Pickup burst | 12-15 | 0.3 | 1.0 | Same colors as idle, brighter |
