---
title: "Play a sound effect in a C# game"
url: https://recursiveai.org/onegame/cookbook/play-sound-effect/
description: "Play a sound effect in a C# game: load a WAV or Ogg clip, play it on the effects bus with volume, pan and pitch, and test that it played without a sound card. Runnable, with onegame."
version: "1.15.2"
---

# Play a sound effect in a C# game

Create the audio service once with `AudioService.Create()`, load the clip with `game.Assets.LoadSound("data/chime.wav")`, and call `audio.Play(clip, new PlayOptions { ... })` when something happens. The program below rings a bell when you press Space: the simulation counts the rings, and the game plays the chime for each new one, at one of three pitches so repeats don’t sound identical.

```shell
dotnet tool install -g RecursiveAI.OneGame.Cli --version 1.15.2
onegame new chime && cd chime
rm -r art scenarios/*
curl -s --create-dirs -o data/chime.wav \
  https://recursiveai.org/onegame/sounds/chime.wav
```

You need the .NET 10 SDK. `data/chime.wav` is a 0.6-second bell; any WAV, Ogg Vorbis, Opus, FLAC or MP3 file works. Audio comes with the game SDK, so there is no package to add. Replace `Program.cs` with:

```csharp
using System;
using Chime;
using OneGame.Audio;
using OneGame.Host;

internal static class Program
{
    internal static int Main(string[] args)
    {
        // Open the speakers once, at startup. A headless run (onegame test, a replay)
        // records instead, so tests make no noise and can still check what played.
        bool headless = Array.IndexOf(args, "--headless") >= 0;
        AudioServiceOptions? options = headless ? new AudioServiceOptions { Capture = true } : null;
        using AudioService audio = AudioService.Create(options);
        return GameApp.Run(new ChimeGame(audio), args);
    }
}
```

Replace `Game.cs` with:

```csharp
using OneGame.Audio;
using OneGame.Core;
using OneGame.Host;
using OneGame.Platform;
using OneGame.UI;

namespace Chime;

/// <summary>A bell. The simulation counts rings and never plays sound.</summary>
[Entity]
public partial class Bell : Entity
{
    [Synced] public partial int Rings { get; private set; }

    [Command]
    public void Ring() => Rings++;
}

public sealed class ChimeGame(AudioService audio) : GameDefinition
{
    private Asset<Sound>? _chime;
    private Bell? _bell;
    private int _heard;

    public override string Name => "chime";

    public override void RegisterEntities(EntityRegistry registry) =>
        OneGame.Generated.Chime.EntityTypes.Register(registry);

    public override void Setup(World world) => world.Add(new Bell());

    public override void DefineActions(ActionMap actions) =>
        actions.Context("play", c =>
            c.Button("ring", Bind.Key(Key.Space), Bind.Pad(GamepadButton.South)));

    public override void OnEachFrame(GameInstance game)
    {
        // Decoded once and cached. In a Debug run, saving data/chime.wav swaps it in.
        _chime ??= game.Assets.LoadSound("data/chime.wav");

        var bell = (Bell)game.World.Entities[0];
        if (!ReferenceEquals(bell, _bell))
        {
            // A new world (a loaded save, a replay, the next test): old rings aren't news.
            (_bell, _heard) = (bell, bell.Rings);
        }

        if (game.Actions.Pressed("ring"))
        {
            bell.Send.Ring();
        }

        // Sound is output, like drawing: play it when the state changes,
        // so replays and every client hear the same.
        if (bell.Rings != _heard)
        {
            _heard = bell.Rings;
            audio.Play(_chime.Value, new PlayOptions
            {
                Bus = AudioBus.Sfx,
                Volume = 0.8f,
                Pitch = 1f + 0.06f * (_heard % 3),   // so repeats don't sound identical
            });
        }
    }

    public override void DefineScreens(ScreenSet screens) =>
        screens.Add("main", game => Ui
            .Label(() => $"Space rings the bell: {((Bell)game.World.Entities[0]).Rings}")
            .Padding(Length.Px(8))
            .TextColor(Color.White));
}
```

`onegame run` opens a window: press Space and the chime plays through the default output device.

- **`PlayOptions`**: `Volume` (linear, 1 is as recorded), `Pan` (-1 left to 1 right), `Pitch` (speed and pitch together, 0.0625 to 16), `Loop`, `FadeInSeconds`, `Priority` (which voice wins when all are busy) and `Bus`.
- **`Play` returns a `VoiceId`.** Keep it to change a playing sound with `audio.SetVolume(id, 0.2f, rampSeconds: 0.1)`, `SetPan` or `Stop(id, fadeSeconds)`. A stale id is ignored, so stopping a sound that already ended is safe.
- **Any number at once.** A `Sound` is decoded into memory and can play many times at once, each play its own voice.
- **A new world isn’t news.** The host replaces the world when a save, a replay or a test loads, so the game remembers which `Bell` it was watching and starts counting again without playing.
- **No device, no crash.** On a machine without audio output the service runs silently and the game is otherwise unchanged.

## Test that it played

A scenario can check which sounds the game asked for, with no sound card. Save this as `scenarios/ring.yaml`:

```yaml
name: ring
description: Each press of ring plays the chime once.
steps:
  - step: 2
  - action: ring
  - step: 2
  - expect: { sound: { name: chime, count: 1 } }
  - action: ring
  - step: 2
  - action: ring
  - step: 2
expect:
  - sound: { name: chime, count: 3 }
  - state: { entity: Bell, property: Rings, equals: 3 }
```

```shell
onegame test
```

```text
PASS ring (44 ms, tick 8, hash bdf10b7fc3c12c4d)
1 passed, 0 failed
```

`sound:` counts the plays since the scenario began, by the clip’s name: `chime` matches the clip loaded from `data/chime.wav`. Add `frames: 5` to count only the last five frames. `onegame test --json` lists every play in each result’s `sounds`, with the frame, bus, volume and whether it started.

## Volume settings and music

The service has three buses: `AudioBus.Music`, `AudioBus.Sfx` and `AudioBus.Ui`. Each play is scaled by its bus volume and by `MasterVolume`.

In a settings screen or a pause menu:

```csharp
audio.SetBusVolume(AudioBus.Sfx, 0.5f);      // effects at half volume
audio.SetBusMuted(AudioBus.Music, true);     // mute keeps the volume for later
audio.MasterVolume = 0.8f;
audio.Paused = true;                         // a pause menu: sounds hold where they are
```

Music streams from the file instead of loading it whole, and a new track replaces the old one with a crossfade: `audio.PlayMusic(File.OpenRead("data/theme.ogg"), new MusicOptions { CrossfadeSeconds = 2 })`.

## Limits

- **How this page was checked.** The test output above is a headless run, which records instead of opening the device. Separately, the same clip was played through this machine’s ALSA output with the 1.15.2 package (resampled from 44.1 to 48 kHz, no underruns); the windowed game wasn’t run for this page.
- **Clips are held in memory**, up to 120 seconds each by default. Stream anything longer with `PlayMusic`.
- **Stereo pan, not 3D.** There is no listener or distance model: set `Pan` and `Volume` from the source’s position yourself.
- **The web needs a click first.** Browsers keep audio suspended until the player interacts with the page, and sounds asked for before that are dropped.
- **Create the service before loading.** `LoadSound` throws `AssetException` with code `no_audio` if no service exists yet.

Loading files, caching and reloading are in [Assets](https://recursiveai.org/onegame/docs/assets/), and scenario checks in [Driving a game](https://recursiveai.org/onegame/docs/driving-a-game/).
