Sound is 50% of the perceived quality of a game. Silent games feel like prototypes even when they're finished. Particles are the second most effective polish tool per hour of effort. This lesson covers both, in the "minimum viable polish" dose.
Godot has three audio player node types:
AudioStreamPlayer โ non-positional, plays at a volume. Use for music and UI clicks.AudioStreamPlayer2D โ positional in 2D world. Use for in-world sounds.AudioStreamPlayer3D โ 3D. Not relevant for us.Import sounds: drop .wav or .ogg files into your project's assets/audio/ folder. Godot imports them automatically. Use .ogg for music (compressed) and .wav for short SFX (low latency).
# scripts/sfx.gd โ autoload "SFX"
extends Node
var players: Array[AudioStreamPlayer] = []
const POOL_SIZE := 8
func _ready() -> void:
for i in POOL_SIZE:
var p := AudioStreamPlayer.new()
add_child(p)
players.append(p)
func play(stream: AudioStream, volume_db: float = 0.0, pitch: float = 1.0) -> void:
for p in players:
if not p.playing:
p.stream = stream
p.volume_db = volume_db
p.pitch_scale = pitch
p.play()
return
push_warning("SFX pool exhausted")
Pool pattern โ avoids creating a node per sound and handles overlapping sounds gracefully.
# Playing a sound
const CARD_TAP := preload("res://audio/card_tap.wav")
SFX.play(CARD_TAP)
# With a random pitch for variety
SFX.play(CARD_TAP, 0.0, randf_range(0.9, 1.1))
Go Audio โ Open Audio Bus Editor (bottom panel). Create buses: Master, Music, SFX, UI. Route your AudioStreamPlayers to the appropriate bus (in their Inspector, set the Bus property). Now players can adjust volume per category in settings.
AudioServer.set_bus_volume_db(AudioServer.get_bus_index("Music"), linear_to_db(0.5))
# 0.5 = -6 dB
Music: this is where paying a few dollars is worth it. A single ~2-minute loop from itch.io ($5โ15) elevates everything. Or Kevin MacLeod's incompetech.com has CC-BY royalty-free tracks if you need truly free.
For Lexicon Duel you need:
That's ~12 files. Not much. But each needs to be curated โ a single bad SFX drags the whole game down.
Use GPUParticles2D for game-world effects. CPUParticles2D if you're worried about mobile GPU (generally unnecessary on modern phones).
Basic setup: add a GPUParticles2D node, assign a ParticleProcessMaterial to its Process Material property, assign a texture to the Texture property (or leave null for dots), hit Emit. Tweak:
# Spawn a one-shot burst
const IMPACT := preload("res://scenes/impact_particles.tscn")
func spawn_impact(at: Vector2) -> void:
var p := IMPACT.instantiate()
add_child(p)
p.global_position = at
p.emitting = true
# auto-free after its lifetime
await get_tree().create_timer(1.0).timeout
p.queue_free()
Make the first, use it everywhere. Each variation is a duplicate of the base scene with one property tweaked.
A sudden silent-to-loud music cut feels amateur. Always fade in:
func play_music(stream: AudioStream) -> void:
if $Music.stream == stream and $Music.playing:
return
var t := create_tween()
t.tween_property($Music, "volume_db", -60, 0.4) # fade out
t.tween_callback(func():
$Music.stream = stream
$Music.play()
)
t.tween_property($Music, "volume_db", 0, 0.8) # fade in
assets/audio/.impact_particles.tscn. Spawn it on enemy hit.