Part 01 — Your first sound
You will build: one hit sound on the damage path, one pickup sound, a volume you can
defend in decibels, and the first audio bug — the pile-up — met and understood.
You'll learn: AudioStreamPlayer · import formats and when each is the right one ·
volume_db and why the number is not a percentage · the rule from the overview, installed
for the first time.
Why this exists#
The cheapest possible audio is one node, one file, and one signal connection. Everything in the rest of the shelf — pools, buses, crossfades, the clock argument — is this shape at a larger scale. Get the shape right once, at the smallest size, and the scale-ups are placements, not inventions.
Build it#
A — The node and the file#
Add an AudioStreamPlayer anywhere in the scene (its position doesn't matter — that's the
3D part, part 05; in 2D it's a plain mixer input). Drop a sound file into res://audio/ —
a short hit, ~0.1–0.3 s, and a pickup, ~0.1 s. Free one-shots: the freesound.org and kenney.nl libraries are the standard
sources, both permissively licensed — check the specific file's license, not the site's.
The import decides the format trade:
| Format | Import | Right when |
|---|---|---|
WAV (.wav) | uncompressed | short SFX under ~1 s: no decode cost at play, largest on disk |
Ogg Vorbis (.ogg) | compressed, with loop points | music, and long SFX: small on disk, decoded at import into a fast-to-play stream |
MP3 (.mp3) | compressed | legacy sources you already have; Ogg is the better default |
The practical rule: SFX short, WAV; everything that loops, OGG. A 0.15 s hit as OGG is fine (the decode is trivial); a 90 s score as WAV is a 10 MB file for no benefit. Set the choice in the file's Import dock — Compress Mode for WAV, and for OGG the Loop section is where the score's loop points live (part 02 needs them; a looped OGG without loop points clicks at the seam).
B — Play it from the event, not the frame#
The survivors game's damage path (stage 04) ends at EnemyStats._on_damage_received — the
same method that spawns the float text and moves the health bar. The sound goes there,
because that is the frame the damage happened:
# in the scene, alongside the float text
@onready var hit_sound: AudioStreamPlayer = $HitSound # stream = your hit.wav
func _on_damage_received(amount: int) -> void:
health -= amount
# ... float text, as stage 04 has it ...
hit_sound.play()
Three things about that one line:
play()restarts from the top. Callingplay()while the previous hit is still sounding cuts it off and starts a new one — on a single player, which is exactly the behaviour you want for a hit sound (the newest hit is the truth) and exactly the behaviour that breaks at scale (part 03). One player is the correct first answer; knowing its ceiling is the correct first limit.- The stream is assigned in the scene, not loaded in code — the
@export-equivalent of audio is the inspector's Stream property. Swapping the hit sound is a file change, not a code change: the definition/state split applied to a wav. - It rides the signal. The float text, the knockback (stage 04's hitbox), and the sound
all leave
damage_receivedin the same frame. That's the overview's rule — sound rides the event — and the reason the hit reads as one moment instead of three.
The pickup sound rides the magnet's crediting moment, the same way: the survivors game's
Exp calls gain_exp when it reaches stop distance (stage 06) — the sound plays there,
not when the drop first touches the pickup area. The arc (stage 06's push phase) is the
promise; the credit is the fact; the sound is a fact.
C — Volume, in the unit it actually is#
AudioStreamPlayer.volume_db — decibels, not a 0–1 percentage. The mapping that matters:
0dB = the stream's native loudness (unity — the file plays as recorded).−12dB ≈ half as loud to the ear.−6dB ≈ 70% — the standard "present but not leading" level.- Positive values make it louder than the file — clipping territory, which is part 03's limiter's reason to exist.
The first mistake is reaching for "50% volume" and typing 0.5 into a field that expects
decibels — 0.5 dB is slightly louder than the file, not half. (Godot's old linear
volume property is deprecated for exactly this reason: the human ear doesn't read linear
amplitude, and a percentage UI for a logarithmic sense is a liar with a slider.) Start every
SFX at −6 dB and tune from there, ear first, Monitor second.
D — The pile-up, met on purpose#
Now the first audio bug, deliberately: make the hit sound longer than the gap between hits
— a 0.5 s sound on a weapon that swings every 0.2 s (the survivors stage 07 faster_swing
cards make this free). Play it.
Predict before you run: with one
AudioStreamPlayerand a 0.5 s sound on a 0.2 s cadence, how many sounds are sounding at any moment — and what does that read as?
One, always: each play() cuts the previous one off at 0.2 s, so the sound you hear is a
0.2 s fragment of a 0.5 s recording, restarted 5 times a second. It reads as a buzz, not
a hit — the tail, which is where a sound's identity lives (the decay is the "thock"), never
arrives. This is the pile-up in its single-player form: not too many sounds, but a sound
that's never allowed to finish. Part 03's pool is the general fix (N players, round-robin);
the single-player symptom is the one you should recognize in a mix that "sounds thin at
speed" — the cadence outran the sound.
E — Commit#
git add . && git commit -m "audio 01: first hit and pickup sounds on the event path, volume_db"
Checkpoint — definition of done#
- The hit sound plays on damage — verify it's on the signal, not the swing: kill the float text temporarily and the sound still lands on the frame the number would have been
- The pickup sound plays when the magnet credits, not when it first touches — walk slow, and the sound arrives a beat after contact, at the end of the arc
-
volume_dbis set on both, and you can say what−6means in plain language ("present but not leading"), not just what it means in the inspector - The pile-up prediction ran and came out the way you said — a buzz, a sound that never finishes — and you can name the fix you're not building yet (the pool, part 03)
- Zero warnings; committed
Stretch (no instructions)#
A per-species hit: the golem's hit is a wooden thock, the brute's (stage 03's stretch)
is a lower one. The sound is data on the enemy's definition — an @export var hit_sound
on EnemyStats, played from the same damage_received. If you find yourself adding an
if enemy is Brute branch in the player's code, the data went to the wrong home.
If you get stuck#
- Nothing plays, no error → the stream property is empty (the file wasn't assigned in the
inspector), or the import failed — the file's Import dock shows the state, and a WAV
with a sample rate the editor didn't like re-imports on a right-click Reimport. Print
hit_sound.streamonce:nullis the answer. - The sound plays but everywhere at once is the wrong phrase — it plays, and it's fine,
and the problem is it's on the wrong frame: you called
play()in the weapon's swing start (the animation), not the damage signal. The whoosh belongs there (part 04); the thock doesn't. Move the line. - The volume is "still too loud" at
−12dB → the file is hot (recorded near clipping). The player's volume can't fix a file that's already red; normalize the source, or ride it at a lowervolume_dband accept the headroom loss. The Monitor's peak meter shows which one you have. - Two sounds, one file, different scenes — and changing the file changes both → that's correct (they share the imported stream) and it's the never-mutate rule wearing an audio costume. Want them different? Two files, or two players with two streams.