How to Use Audio in Rust Game Development

Use the rodio crate to load audio files into a Source and play them through a Sink in Rust games.

The silence problem

You just finished the jump mechanic in your platformer. The physics feel snappy, the collision detection works, but when the character hits the ground, there is total silence. The game feels like a ghost town. You need sound. Rust's standard library does not include audio support. You have to reach for a crate. For most game developers starting out, rodio is the go-to tool. It handles decoding, mixing, and playback without forcing you to write your own audio engine.

Audio as a stream of numbers

Audio playback in Rust works like a plumbing system. You have a source of data, a pipe to carry it, and a destination. rodio models this with two main types: Source and Sink. A Source is anything that produces audio samples. It could be a file on disk, a generated tone, or a network stream. Under the hood, a Source is just an iterator that yields f32 values representing amplitude. The compiler does not care about sound. It cares about types and iterators.

A Sink is a queue that feeds audio into the operating system's audio device. You do not play audio directly from a file. You attach a Source to a Sink, and the Sink manages the timing, volume, and mixing. The sink runs on a background thread, pulling samples from the queue and pushing them to the speakers. Treat audio as data. The compiler enforces the structure, not the semantics.

Minimal playback

Here is the smallest working example to play a sound file.

use rodio::{Sink, source::Decoder};
use std::fs::File;
use std::io::BufReader;

fn main() {
    // Get the default audio device and a stream handle.
    let (_stream, handle) = rodio::OutputStream::try_default().expect("Audio device failed");
    
    // Create a Sink to manage playback on this device.
    let sink = Sink::try_new(&handle).expect("Sink creation failed");

    // Open the audio file and wrap it in a buffered reader for efficiency.
    let file = File::open("jump.wav").expect("File not found");
    let buffered = BufReader::new(file);
    
    // Decode the file into a Source that produces samples.
    let source = Decoder::new(buffered).expect("Invalid audio format");

    // Append the source to the sink. Playback starts immediately.
    sink.append(source);

    // Keep the main thread alive so the sink can finish playing.
    sink.sleep_until_end();
}

Add this to your Cargo.toml:

[dependencies]
rodio = "0.18"

Convention: Buffering and blocking

The community convention is to wrap file handles in BufReader before passing them to Decoder. Reading raw bytes from disk triggers system calls for every small chunk. Buffering batches these reads and keeps the audio stream smooth. Also, prefer sink.sleep_until_end() over std::thread::sleep. Hard-coding a sleep duration breaks if the track length changes. Let the sink tell you when it is done.

How the sink works

When you run this code, OutputStream::try_default asks the OS for the default audio device. It returns a stream handle and a device handle. The device handle is what you pass to Sink::try_new. The sink creates a background thread that reads from its internal queue and pushes samples to the OS. When you call sink.append, you are not starting playback yourself. You are handing the source to the sink's queue. The sink's background thread pulls samples from the source and sends them to the speakers. If you append multiple sources, the sink plays them one after another. The sleep_until_end call blocks the main thread until the sink's queue is empty and all audio has finished playing. The sink is a background worker. Feed it data and let it run.

Realistic game loop

In a real game, you cannot block the main thread waiting for audio to finish. Your game loop needs to keep running to handle input and rendering. You also need to play sound effects on demand, not just one file at startup. Here is how you structure audio for a game loop.

use rodio::{Sink, source::Decoder};
use std::fs::File;
use std::io::BufReader;

/// Plays a sound effect and returns immediately.
fn play_sound(sink: &Sink, filename: &str) {
    let file = File::open(filename).expect("File not found");
    let source = Decoder::new(BufReader::new(file)).expect("Invalid format");
    sink.append(source);
}

fn main() {
    let (_stream, handle) = rodio::OutputStream::try_default().expect("Audio failed");
    let sink = Sink::try_new(&handle).expect("Sink failed");

    // Play background music.
    let music = Decoder::new(BufReader::new(File::open("bgm.ogg").unwrap())).unwrap();
    sink.append(music);

    // Simulate game loop.
    for _ in 0..10 {
        // Check input, update state, render...
        std::thread::sleep(std::time::Duration::from_millis(100));
        
        // Play a sound effect.
        play_sound(&sink, "jump.wav");
    }

    // Game over. Wait for remaining audio.
    sink.sleep_until_end();
}

Keep the game loop moving. Audio should never stall your logic.

Convention: Memory vs disk

Loading from disk every time you play a sound can cause hitches. For sound effects, load the file into memory once, then play from memory. Use std::io::Cursor.

use std::io::Cursor;

// Load once.
let data = std::fs::read("jump.wav").expect("Read failed");

// Play from memory.
let cursor = Cursor::new(data);
let source = Decoder::new(cursor).expect("Decode failed");
sink.append(source);

For sound effects, load the file into memory once using std::fs::read, then play from a Cursor. Disk I/O during gameplay causes hitches. Music tracks are large. Keep them on disk and stream them with File. Use Cursor for small assets, File for large streams.

Chaining effects

rodio treats audio as a composable stream. Every effect is just another Source that wraps the previous one. You can chain effects like pipes. The chain is evaluated lazily. Samples are generated on demand as the sink pulls them. This is efficient because you do not create intermediate buffers.

use std::time::Duration;

// Chain effects before appending.
let source = Decoder::new(buffered).unwrap()
    .amplify(0.5)       // Reduce volume by half.
    .speed(1.5)         // Speed up playback.
    .fade_in(Duration::from_secs(2)); // Fade in over 2 seconds.

sink.append(source);

You can combine volume, speed, panning, and fading in a single chain. Build your audio pipeline. Chain effects before you append to the sink.

Pitfalls and compiler errors

If you try to wrap a Sink in Arc and move it to a thread, the compiler rejects you with E0277 (trait bound not satisfied). Sink is not Send. This restriction exists because the underlying audio stream is tied to the thread that created it. If you need to trigger sounds from other threads, send a message to the main thread via a channel, or use a different audio crate designed for multi-threading.

Decoder requires feature flags for specific formats. If you add rodio = "0.18" without enabling mp3 or vorbis, the decoder will panic when you try to load those files. Check your Cargo.toml features before blaming the file format.

Storing sources in a collection requires type erasure. You cannot create a Vec<Source> because Source is a trait. You need Vec<Box<dyn Source>>. If you try to store sources directly, the compiler complains about trait objects. Box the sources to erase their types.

Decision matrix

Use rodio when you need simple playback with minimal setup. It handles decoding, mixing, and volume control out of the box. It works well for small games, prototypes, and tools where audio is secondary to logic.

Use symphonia when you need fine-grained control over decoding without playback management. It is a pure decoder library. You handle the sample stream yourself. This is useful if you are building your own audio engine or need to process audio data before sending it to a device.

Use cpal when you need low-level access to the audio device. It provides raw access to input and output streams. You are responsible for decoding, mixing, and sample conversion. Reach for this only when you are writing a custom audio framework or have strict latency requirements that higher-level crates cannot meet.

Use bevy_audio when you are already using the Bevy game engine. It integrates directly with Bevy's entity-component system and handles asset loading automatically. Do not add rodio to a Bevy project. Use the engine's built-in audio support.

Pick the tool that matches your abstraction level. Do not write a decoder if you just want to play a file.

Where to go next