PVG
38. Sound Effects — Program Video Games

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:

  1. Initialise Raylib's audio device
  2. Build a small sound effect system with aliases and pitch randomisation
  3. 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 sound is 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_init now comes before assets_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

  1. 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.
  2. Try setting AUDIO_MAX_ALIASES to 1 and rapidly triggering the same sound. Listen to the difference (it should sound harsh - like it's getting clipped), then set it back.
  3. 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.