Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 7: Sound Effects

Games feel incomplete without audio. So far we’ve been using draw_rect() for everything—but you can’t draw a sound! This is where Emberware’s asset pipeline comes in.

What You’ll Learn

  • Setting up an assets folder
  • Creating ember.toml to bundle assets
  • Using ember build instead of cargo build
  • Loading sounds with rom_sound()
  • Playing sounds with play_sound() and stereo panning

Why Assets Now?

Up until now, we’ve built and tested like this:

cargo build --target wasm32-unknown-unknown --release
ember run target/wasm32-unknown-unknown/release/paddle.wasm

This works great for graphics—draw_rect() handles everything. But sounds need actual audio files. That’s where ember build comes in: it bundles your code and assets into a single ROM file.

Create the Assets Folder

Create an assets/ folder in your project:

mkdir assets

Get Sound Files

You need three WAV files for the game:

SoundDescriptionDuration
hit.wavQuick beep for paddle/wall hits~0.1s
score.wavDescending tone when someone scores~0.2s
win.wavVictory fanfare when game ends~0.5s

Download sample sounds from the tutorial assets, or create your own with:

Put them in your assets/ folder:

paddle/
├── Cargo.toml
├── ember.toml          ← We'll create this next
├── assets/
│   ├── hit.wav
│   ├── score.wav
│   └── win.wav
└── src/
    └── lib.rs

Create ember.toml

Create ember.toml in your project root. This manifest tells Emberware about your game and its assets:

[game]
id = "paddle"
title = "Paddle"
author = "Your Name"
version = "0.1.0"

# Sound assets
[[assets.sounds]]
id = "hit"
path = "assets/hit.wav"

[[assets.sounds]]
id = "score"
path = "assets/score.wav"

[[assets.sounds]]
id = "win"
path = "assets/win.wav"

Each asset has:

  • id — The name you’ll use to load it in code
  • path — File location relative to ember.toml

Build with ember build

Now use ember build instead of cargo build:

ember build

This command:

  1. Compiles your Rust code to WASM
  2. Converts WAV files to the optimized format (22050 Hz mono)
  3. Bundles everything into a paddle.ewzx ROM file

You’ll see output like:

Building paddle...
  Compiling paddle v0.1.0
  Converting hit.wav → hit.ewzsnd
  Converting score.wav → score.ewzsnd
  Converting win.wav → win.ewzsnd
  Packing paddle.ewzx (28 KB)
Done!

Run Your Game

Now run the ROM:

ember run paddle.ewzx

Or just:

ember run

This builds and runs in one step.

Add Audio FFI

Add the audio functions to your FFI imports:

#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
    // ... existing imports ...

    // ROM loading
    fn rom_sound(id_ptr: *const u8, id_len: u32) -> u32;

    // Audio playback
    fn play_sound(sound: u32, volume: f32, pan: f32);
}
}

Sound Handles

Add static variables to store sound handles:

#![allow(unused)]
fn main() {
static mut SFX_HIT: u32 = 0;
static mut SFX_SCORE: u32 = 0;
static mut SFX_WIN: u32 = 0;
}

Load Sounds in init()

Update init() to load sounds from the ROM:

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn init() {
    unsafe {
        set_clear_color(0x1a1a2eFF);

        // Load sounds from ROM
        SFX_HIT = rom_sound(b"hit".as_ptr(), 3);
        SFX_SCORE = rom_sound(b"score".as_ptr(), 5);
        SFX_WIN = rom_sound(b"win".as_ptr(), 3);

        reset_game();
        STATE = GameState::Title;
    }
}
}

The rom_sound() function loads the sound directly from the bundled ROM—the string IDs match what you put in ember.toml.

Play Sounds

Now add sound effects to game events:

Wall Bounce

#![allow(unused)]
fn main() {
// In update_ball(), after wall bounce:
if BALL_Y <= 0.0 {
    BALL_Y = 0.0;
    BALL_VY = -BALL_VY;
    play_sound(SFX_HIT, 0.3, 0.0);  // Center pan
}

if BALL_Y >= SCREEN_HEIGHT - BALL_SIZE {
    BALL_Y = SCREEN_HEIGHT - BALL_SIZE;
    BALL_VY = -BALL_VY;
    play_sound(SFX_HIT, 0.3, 0.0);
}
}

Paddle Hit

#![allow(unused)]
fn main() {
// In paddle 1 collision:
play_sound(SFX_HIT, 0.5, -0.5);  // Pan left

// In paddle 2 collision:
play_sound(SFX_HIT, 0.5, 0.5);   // Pan right
}

Scoring

#![allow(unused)]
fn main() {
// When player 2 scores (ball exits left):
SCORE2 += 1;
play_sound(SFX_SCORE, 0.6, 0.5);  // Pan right (scorer's side)

// When player 1 scores (ball exits right):
SCORE1 += 1;
play_sound(SFX_SCORE, 0.6, -0.5);  // Pan left (scorer's side)
}

Win

#![allow(unused)]
fn main() {
// When either player wins:
if SCORE1 >= WIN_SCORE || SCORE2 >= WIN_SCORE {
    STATE = GameState::GameOver;
    play_sound(SFX_WIN, 0.8, 0.0);  // Center, louder
}
}

Understanding play_sound()

#![allow(unused)]
fn main() {
fn play_sound(sound: u32, volume: f32, pan: f32);
}
ParameterRangeDescription
soundHandleSound handle from rom_sound()
volume0.0 - 1.00 = silent, 1 = full volume
pan-1.0 - 1.0-1 = left, 0 = center, 1 = right

Audio Specs

Emberware uses these audio settings:

PropertyValue
Sample rate22050 Hz
Format16-bit mono PCM
ChannelsStereo output

The ember build command automatically converts your WAV files to this format.

Sound Design Tips

  1. Keep sounds short — 0.1 to 0.5 seconds is plenty for effects
  2. Use panning — Stereo positioning helps players track action
  3. Vary volume — Important sounds louder, ambient sounds quieter
  4. Match your aesthetic — Simple sounds fit retro games

Build and Test

Rebuild with your sound assets:

ember build
ember run

The game now has:

  • “Ping” sound when ball hits walls or paddles
  • Different panning for left/right paddle hits
  • Descending “whomp” when someone scores
  • Victory fanfare when a player wins

Bonus: Sprite Graphics

Now that we have the asset pipeline set up, we can also use image sprites instead of draw_rect(). This is optional—the game works fine with rectangles—but sprites look nicer!

Add Texture Assets

Download paddle.png and ball.png from the tutorial assets, then add them to ember.toml:

# Texture assets
[[assets.textures]]
id = "paddle"
path = "assets/paddle.png"

[[assets.textures]]
id = "ball"
path = "assets/ball.png"

Add Texture FFI

#![allow(unused)]
fn main() {
#[link(wasm_import_module = "env")]
extern "C" {
    // ... existing imports ...

    // Texture loading and drawing
    fn rom_texture(id_ptr: *const u8, id_len: u32) -> u32;
    fn texture_bind(texture: u32);
    fn draw_sprite(x: f32, y: f32, w: f32, h: f32);
}
}

Load Textures

Add handles and load in init():

#![allow(unused)]
fn main() {
static mut TEX_PADDLE: u32 = 0;
static mut TEX_BALL: u32 = 0;

// In init():
TEX_PADDLE = rom_texture(b"paddle".as_ptr(), 6);
TEX_BALL = rom_texture(b"ball".as_ptr(), 4);
}

Draw Sprites

Replace draw_rect() calls in render():

#![allow(unused)]
fn main() {
// Instead of: draw_rect(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT, COLOR_PLAYER1);
texture_bind(TEX_PADDLE);
draw_sprite(PADDLE_MARGIN, PADDLE1_Y, PADDLE_WIDTH, PADDLE_HEIGHT);

// Second paddle
draw_sprite(SCREEN_WIDTH - PADDLE_MARGIN - PADDLE_WIDTH, PADDLE2_Y, PADDLE_WIDTH, PADDLE_HEIGHT);

// Instead of: draw_rect(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE, COLOR_WHITE);
texture_bind(TEX_BALL);
draw_sprite(BALL_X, BALL_Y, BALL_SIZE, BALL_SIZE);
}

The sprite will be tinted by the bound texture. You can also use draw_sprite_colored() if you want to tint sprites with different colors per player.

New Workflow Summary

Before (Parts 1-6)Now (Part 7+)
cargo build --target wasm32-unknown-unknown --releaseember build
ember run target/.../paddle.wasmember run
No assets neededAssets bundled in ROM

From now on, just use ember build and ember run!


Next: Part 8: Polish & Publishing — Final touches and releasing your game.