Quick answer: Call particles.restart() instead of toggling emitting. For variation between bursts, randomize process_material.seed before each restart.

An explosion particle effect plays once when an enemy dies. The same emitter is reused for the next enemy death — nothing happens. The first burst worked; the second is silent. The particles node is still in the tree, still configured, still has one_shot = true. Setting emitting = true is a no-op.

Why emitting Doesn’t Restart

For one-shot emitters, emitting is more of a status flag than a control. The first time it goes true, particles spawn. When the burst completes, the emitter sets it back to false internally. Setting it to true again does nothing because the emitter has already used its one allotted burst — it expects you to call restart() to begin a new cycle.

The Fix

@onready var explosion: GPUParticles2D = $ExplosionParticles

func play_explosion():
    explosion.global_position = enemy_position
    explosion.restart()

restart() rewinds the emitter to time zero and triggers a fresh burst. It works whether the emitter is currently active or idle.

Adding Visual Variety

If you reuse the same emitter many times, identical bursts look noticeable. Randomize the seed each call:

func play_explosion():
    explosion.global_position = enemy_position
    var mat = explosion.process_material as ParticleProcessMaterial
    mat.set_param_randomness(ParticleProcessMaterial.PARAM_SCALE, randf_range(0.3, 0.7))
    explosion.restart()

You can also tweak scale, speed, color over time per burst. Avoid swapping the entire process_material — that allocates a new resource each call.

Pooling for High-Frequency Bursts

If explosions happen rapidly (e.g., bullet impacts), a single emitter can’t produce overlapping bursts. Pool a handful:

var pool: Array[GPUParticles2D] = []
var next_index = 0

func _ready():
    for i in 8:
        var e = explosion_scene.instantiate()
        add_child(e)
        pool.append(e)

func play_explosion(pos: Vector2):
    var e = pool[next_index]
    next_index = (next_index + 1) % pool.size()
    e.global_position = pos
    e.restart()

Eight emitters round-robined gives you eight concurrent bursts with zero allocation cost after init.

Verifying

Add a counter that increments each call. Watch particles.emitting in the Debugger → Remote Inspector during play. After fix, you should see emitting briefly flicker true and back to false on every restart() call, with visible particles in the viewport.

“One-shot particles are restart-driven, not flag-driven. Don’t toggle emitting — call restart().”

Pool 4–8 of any reused emitter. The cost is tiny; the responsiveness gain on rapid bursts is huge.