doc 2 of 6

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:

FormatImportRight when
WAV (.wav)uncompressedshort SFX under ~1 s: no decode cost at play, largest on disk
Ogg Vorbis (.ogg)compressed, with loop pointsmusic, and long SFX: small on disk, decoded at import into a fast-to-play stream
MP3 (.mp3)compressedlegacy 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. Calling play() 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_received in 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_dbdecibels, not a 0–1 percentage. The mapping that matters:

  • 0 dB = the stream's native loudness (unity — the file plays as recorded).
  • −12 dB ≈ half as loud to the ear.
  • −6 dB ≈ 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 AudioStreamPlayer and 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_db is set on both, and you can say what −6 means 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.stream once: null is 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 −12 dB → 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 lower volume_db and 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.