38. Sound Effects
The game is silent. Menus highlight without feedback, attacks land without impact. Even placeholder sound effects make a surprising difference to how a game feels, so let's fix that.
In this lesson we'll:
- Initialise Raylib's audio device
- Build a small sound effect system with aliases and pitch randomisation
- Hook it into combat and UI
Initialise Audio
Raylib requires you to explicitly initialise and close the audio device. In main.odin, add these after InitWindow:
rl.InitAudioDevice()
defer rl.CloseAudioDevice()
rl.SetMasterVolume(0.5)
defer ensures cleanup happens when the program exits, same pattern we use for the window. SetMasterVolume takes a float from 0 to 1. Half volume is a reasonable default so your placeholder effects don't blow your ears out during development.
The Alias Problem
Raylib's Sound type can only play one instance at a time. If you call PlaySound on a sound that's already playing, it restarts from the beginning. For a menu tap, that's probably fine. For rapid attacks or footsteps, you get a stuttery mess where each new play cuts off the previous one.
Raylib's solution is LoadSoundAlias. An alias shares the same audio data as the original sound (no extra memory for the samples), but has its own playback state. If you create 8 aliases, you can have 8 overlapping plays of the same sound before the oldest one gets recycled.
audio.odin
Create a new file audio.odin:
package main
import "core:log"
import "core:math/rand"
import "vendor:raylib"
AUDIO_MAX_ALIASES :: 8
Sound_Effect :: struct {
aliases: [AUDIO_MAX_ALIASES]raylib.Sound,
alias_index: int,
}
AUDIO_MAX_ALIASES controls how many overlapping plays you support per sound. 8 is generous for an RPG. A bullet-hell might want more.
alias_index tracks which alias to use next. We'll cycle through them round-robin.
Loading
sfx_load :: proc(sfx_key: string, path: cstring) {
sound := rl.LoadSound(path)
gs.assets.sfx[sfx_key] = {}
if sfx, ok := &gs.assets.sfx[sfx_key]; ok {
for i in 0 ..< AUDIO_MAX_ALIASES {
sfx.aliases[i] = rl.LoadSoundAlias(sound)
}
} else {
log.errorf("Unable to create sound aliases for '%s':'%s'", sfx_key, path)
}
}
We load the base sound once, then create 8 aliases from it. The base sound holds the sample data. Each alias is a lightweight handle that can play independently.
Note that the base
soundis local and not stored anywhere. Raylib keeps the audio data alive internally. The aliases reference it by handle, not by pointer, so this is safe.
Playback with Pitch Randomisation
sfx_play :: proc(sfx_key: string, pitch_range := [2]f32{0.9, 1.1}) {
if sfx, ok := &gs.assets.sfx[sfx_key]; ok {
sound := sfx.aliases[sfx.alias_index]
pitch := rand.float32_range(pitch_range[0], pitch_range[1])
rl.SetSoundPitch(sound, pitch)
rl.PlaySound(sound)
sfx.alias_index += 1
sfx.alias_index %= AUDIO_MAX_ALIASES
} else {
log.errorf("Unable to play sound '%s'", sfx_key)
}
}
Two things happening here.
Round-robin aliasing. Each call advances alias_index and wraps with modulo. The first play uses alias 0, the second uses alias 1, and so on. After alias 7, it wraps back to 0. If alias 0 is still playing when we wrap around, it gets cut off, but you'd need 8 overlapping plays of the same effect for that to happen.
Pitch randomisation. Each play gets a random pitch between 0.9 and 1.1 (by default). This is a classic trick. Without it, repeating the same sound effect at the same pitch sounds mechanical and artificial. Your brain is very good at noticing exact repetition. A +/-10% pitch variation is enough to break the pattern without making it sound wrong. The caller can override the range if a specific effect needs tighter or wider variation.
Update Assets_State
Add the sfx map to game.odin:
Assets_State :: struct {
textures: map[string]raylib.Texture,
models: map[string]raylib.Model,
sfx: map[string]Sound_Effect,
}
Same pattern as textures and models. String key, data-driven lookup.
Update assets_init
We already load textures by scanning a directory. Sound effects work the same way. Restructure assets_init to drop its parameters (it can access gs directly) and add an sfx loading block:
assets_init :: proc() {
// Load textures
{
dir, dir_err := os.open("assets/textures")
if dir_err != nil {
log.panicf("%v", dir_err)
}
defer os.close(dir)
file_infos, fi_err := os.read_dir(dir, 0, context.temp_allocator)
if fi_err != nil {
log.panicf("%v", fi_err)
}
for file_info in file_infos {
texture := rl.LoadTexture(fmt.ctprintf(file_info.fullpath))
key := strings.clone(file_info.name)
key = strings.trim_suffix(key, ".png")
gs.assets.textures[key] = texture
emit(Event{debug_message = fmt.tprintf("Loaded texture `%s`", key)})
}
}
// Load sfx
{
dir, dir_err := os.open("assets/sfx")
if dir_err != nil {
log.panicf("%v", dir_err)
}
defer os.close(dir)
file_infos, fi_err := os.read_dir(dir, 0, context.temp_allocator)
if fi_err != nil {
log.panicf("%v", fi_err)
}
for file_info in file_infos {
key := strings.clone(file_info.name)
key = strings.trim_suffix(key, ".wav")
key = strings.trim_suffix(key, ".ogg")
key = strings.trim_suffix(key, ".mp3")
sfx_load(key, fmt.ctprintf(file_info.fullpath))
emit(Event{debug_message = fmt.tprintf("Loaded sfx `%s`", key)})
}
}
}
The key stripping handles .wav, .ogg, and .mp3 so you can mix formats without changing any code that references the sound by name.
Since assets_init no longer takes parameters, update the call in game_post_window_create:
debug_init(&gs.debug)
events_init(&gs.events) // moved to before
assets_init()
animations_init(&gs.animations, gs.assets.textures)
Note that
events_initnow comes beforeassets_init. The asset loader emits debug events, so the event system needs to be ready first.
Add Sound Assets
Drop swipe.wav and tap.wav into assets/sfx/. These are placeholder effects. swipe is for attacks, tap is for menu navigation. Any short, punchy samples will do.
Hook Into Battle
In battle.odin, inside battle_apply_action, play the swipe sound when an attack lands:
dmg := actor.atk.curr
target.hp.curr = max(0, target.hp.curr - dmg)
sfx_play("swipe")
One line. The alias system handles overlapping plays if multiple attacks resolve on the same frame.
Hook Into UI
In ui.odin, play a tap sound when the cursor enters a menu item:
if text_status.is_hovered && !disabled {
fui.set_text_color(YELLOW, text_status.handle)
}
if text_status.is_entered {
sfx_play("tap")
}
is_entered fires once when the cursor first enters the element, not every frame it's hovered. Without this distinction, you'd get a tap sound 60 times per second while hovering.
Try It Yourself
- Add a footstep sound that plays while the player is moving on the field map. Use a narrower pitch range (try
{0.95, 1.05}) and throttle it so it doesn't fire every frame. - Try setting
AUDIO_MAX_ALIASESto 1 and rapidly triggering the same sound. Listen to the difference (it should sound harsh - like it's getting clipped), then set it back. - Add a confirmation sound for menu selection (not just hover).
The Big Idea
A sound effect system needs two things beyond basic playback: aliases for overlapping plays, and pitch randomisation to break repetition. Both are cheap to implement and make a big difference.