Chapter 20

Audio System

Build a centralized audio manager with pooled sound effects, looping background music with crossfade, volume controls with persistence, and event-driven sound playback for every game action.

Why Audio Matters

Try playing any game with the sound off, then turn it on. The difference is dramatic. Audio provides critical feedback that makes every action feel impactful:

  • The satisfying ding when you collect a coin confirms the pickup worked before the score even updates.
  • A whoosh on lane changes makes movement feel fast and responsive.
  • Background music sets the mood and builds tension as the game speeds up.
  • The crash on death makes failure feel consequential.

Our audio system needs to handle two fundamentally different types of sound:

  • Music: Long, looping tracks played continuously. Only one plays at a time, with smooth crossfades between tracks.
  • Sound Effects (SFX): Short, one-shot sounds triggered by game events. Multiple SFX can play simultaneously (imagine collecting three coins in rapid succession).

Audio Architecture

Unity plays sound through AudioSource components. Each AudioSource can play one clip at a time. For music, we need one or two sources (for crossfading). For SFX, we need a pool of sources because many effects can overlap.

Our architecture:

  • AudioManager (singleton) — Central controller attached to a persistent GameObject.
  • Music AudioSources — Two AudioSource components for crossfading between tracks.
  • SFX AudioSource Pool — An array of AudioSource components for playing one-shot effects.
  • AudioConfig (ScriptableObject) — Maps SoundType enums to AudioClip references and volume settings.
Why Pool AudioSources?

You might think you can just call AudioSource.PlayClipAtPoint() for SFX. While this works, it creates a new GameObject with an AudioSource every time, which is wasteful. By pre-creating a pool of AudioSource components on the AudioManager, we can reuse them without any allocation. When we need to play a sound, we find an idle source in the pool and use it.

Sound Types

We define an enum for every sound in the game. This serves as a clean, type-safe way to request sounds without passing AudioClip references around. Other systems just say "play the Jump sound" and the AudioManager handles the rest.

SoundType.csC#
namespace InfiniteRunner.Audio
{
    /// <summary>
    /// Enumerates all sound effects in the game.
    /// Each value maps to a specific AudioClip via the AudioConfig.
    /// Adding a new sound is a two-step process:
    /// 1. Add a value to this enum.
    /// 2. Assign the AudioClip in the AudioConfig ScriptableObject.
    /// </summary>
    public enum SoundType
    {
        /// <summary>Player jumps.</summary>
        Jump,

        /// <summary>Player slides under an obstacle.</summary>
        Slide,

        /// <summary>Player changes lanes.</summary>
        LaneChange,

        /// <summary>Player collects a coin.</summary>
        Collect,

        /// <summary>Player picks up a power-up.</summary>
        PowerUp,

        /// <summary>A power-up effect expires.</summary>
        PowerUpExpire,

        /// <summary>Player hits an obstacle and dies.</summary>
        Death,

        /// <summary>Shield absorbs a hit.</summary>
        ShieldBreak,

        /// <summary>UI button is clicked.</summary>
        ButtonClick,

        /// <summary>New high score achieved.</summary>
        HighScore,

        /// <summary>Game countdown before start (3, 2, 1).</summary>
        Countdown
    }
}

AudioConfig ScriptableObject

The AudioConfig maps each SoundType to an AudioClip with an associated volume. This decouples the code from specific audio files — you can swap out any sound effect just by changing the reference in the Inspector.

AudioConfig.csC#
using UnityEngine;

namespace InfiniteRunner.Audio
{
    /// <summary>
    /// ScriptableObject that maps SoundType values to AudioClip
    /// references and per-sound volume settings. Create via
    /// Assets > Create > Infinite Runner > Audio Config.
    /// </summary>
    [CreateAssetMenu(
        fileName = "AudioConfig",
        menuName = "Infinite Runner/Audio Config"
    )]
    public class AudioConfig : ScriptableObject
    {
        [Tooltip("All sound effect entries. Each maps a SoundType " +
                 "to an AudioClip with volume settings.")]
        public SoundEntry[] sounds;

        [Header("Music Tracks")]
        [Tooltip("Background music tracks. The AudioManager will " +
                 "cycle through these or play them randomly.")]
        public MusicTrack[] musicTracks;

        /// <summary>
        /// Finds the SoundEntry for a given SoundType.
        /// Returns null if the type is not configured.
        /// </summary>
        public SoundEntry GetSound(SoundType type)
        {
            if (sounds == null) return null;

            for (int i = 0; i < sounds.Length; i++)
            {
                if (sounds[i].type == type)
                {
                    return sounds[i];
                }
            }

            Debug.LogWarning($"[AudioConfig] No AudioClip configured " +
                             $"for SoundType.{type}");
            return null;
        }
    }

    /// <summary>
    /// Maps a SoundType to an AudioClip with volume and pitch settings.
    /// </summary>
    [System.Serializable]
    public class SoundEntry
    {
        [Tooltip("Which sound type this entry represents.")]
        public SoundType type;

        [Tooltip("The audio clip to play.")]
        public AudioClip clip;

        [Tooltip("Volume for this specific sound (0-1). " +
                 "Combined with the global SFX volume.")]
        [Range(0f, 1f)]
        public float volume = 1f;

        [Tooltip("Random pitch variation range. " +
                 "Adds variety so repeated sounds don't feel robotic. " +
                 "E.g., 0.1 means pitch varies from 0.9 to 1.1.")]
        [Range(0f, 0.5f)]
        public float pitchVariation = 0f;
    }

    /// <summary>
    /// Represents a background music track with volume settings.
    /// </summary>
    [System.Serializable]
    public class MusicTrack
    {
        [Tooltip("Human-readable name for this track.")]
        public string trackName;

        [Tooltip("The music audio clip.")]
        public AudioClip clip;

        [Tooltip("Volume for this track (0-1). " +
                 "Combined with the global music volume.")]
        [Range(0f, 1f)]
        public float volume = 0.5f;
    }
}

Setting Up the AudioConfig

  1. Right-click in Assets/Data > Create > Infinite Runner > Audio Config.
  2. Name it MainAudioConfig.
  3. Expand the Sounds array and add one entry for each SoundType.
  4. For each entry, set the Type, drag in an AudioClip, and set the Volume.
  5. For coin collect, set Pitch Variation to 0.1 so repeated collections sound slightly different.
  6. Expand the Music Tracks array and add your background music clips.
Pitch Variation for Repeated Sounds

When the player collects coins rapidly, hearing the exact same sound 10 times in a row sounds monotonous and artificial. By adding a small pitch variation (0.05-0.15), each play has a slightly different pitch. The sound is still recognizable but feels more natural and lively. This tiny detail is used in almost every professional game.

The Complete AudioManager

The AudioManager is the centerpiece of the audio system. It manages music playback with crossfading, maintains a pool of AudioSource components for SFX, handles volume settings, and connects to game events for automatic sound playback.

AudioManager.csC#
using System.Collections;
using UnityEngine;
using InfiniteRunner.Core;
using InfiniteRunner.Gameplay;

namespace InfiniteRunner.Audio
{
    /// <summary>
    /// Centralized audio system that handles all sound playback.
    /// Features:
    /// - Pooled AudioSources for SFX (no runtime allocation)
    /// - Two music AudioSources for crossfading between tracks
    /// - Volume controls (master, music, SFX) with PlayerPrefs persistence
    /// - Event-driven sound playback for game events
    /// - Mute toggle
    ///
    /// Setup:
    /// 1. Create an empty GameObject named "AudioManager".
    /// 2. Add this component.
    /// 3. Assign your AudioConfig ScriptableObject.
    /// 4. Enter Play Mode - the manager creates its AudioSources automatically.
    /// </summary>
    public class AudioManager : MonoBehaviour
    {
        // ---------------------------------------------------------------
        // Singleton
        // ---------------------------------------------------------------

        public static AudioManager Instance { get; private set; }

        // ---------------------------------------------------------------
        // Inspector Fields
        // ---------------------------------------------------------------

        [Header("Configuration")]
        [Tooltip("The AudioConfig that maps SoundTypes to AudioClips.")]
        [SerializeField] private AudioConfig audioConfig;

        [Header("SFX Pool")]
        [Tooltip("Number of AudioSource components to create for SFX. " +
                 "This is the max number of simultaneous sound effects.")]
        [SerializeField] private int sfxPoolSize = 10;

        [Header("Music")]
        [Tooltip("How long music crossfades take in seconds.")]
        [SerializeField] private float crossfadeDuration = 2f;

        [Tooltip("Whether to start playing music automatically on startup.")]
        [SerializeField] private bool playMusicOnStart = true;

        [Header("Default Volumes")]
        [Tooltip("Default master volume (0-1).")]
        [Range(0f, 1f)]
        [SerializeField] private float defaultMasterVolume = 1f;

        [Tooltip("Default music volume (0-1).")]
        [Range(0f, 1f)]
        [SerializeField] private float defaultMusicVolume = 0.5f;

        [Tooltip("Default SFX volume (0-1).")]
        [Range(0f, 1f)]
        [SerializeField] private float defaultSfxVolume = 0.8f;

        // ---------------------------------------------------------------
        // PlayerPrefs Keys
        // ---------------------------------------------------------------

        private const string MASTER_VOL_KEY = "Audio_MasterVolume";
        private const string MUSIC_VOL_KEY = "Audio_MusicVolume";
        private const string SFX_VOL_KEY = "Audio_SfxVolume";
        private const string MUTE_KEY = "Audio_Muted";

        // ---------------------------------------------------------------
        // Private State
        // ---------------------------------------------------------------

        // SFX pool - array of reusable AudioSource components.
        private AudioSource[] sfxSources;

        // Music sources - two for crossfading.
        private AudioSource musicSourceA;
        private AudioSource musicSourceB;

        // Which music source is currently "active" (playing the main track).
        private AudioSource activeMusicSource;

        // Volume settings.
        private float masterVolume;
        private float musicVolume;
        private float sfxVolume;
        private bool isMuted;

        // Crossfade coroutine reference.
        private Coroutine crossfadeCoroutine;

        // Current music track index.
        private int currentTrackIndex = -1;

        // ---------------------------------------------------------------
        // Unity Lifecycle
        // ---------------------------------------------------------------

        private void Awake()
        {
            // Singleton with DontDestroyOnLoad.
            if (Instance != null && Instance != this)
            {
                Destroy(gameObject);
                return;
            }
            Instance = this;
            DontDestroyOnLoad(gameObject);

            // Create audio sources.
            CreateSFXPool();
            CreateMusicSources();

            // Load saved volume settings.
            LoadVolumeSettings();
        }

        private void Start()
        {
            if (playMusicOnStart && audioConfig != null
                && audioConfig.musicTracks != null
                && audioConfig.musicTracks.Length > 0)
            {
                PlayMusic(0);
            }
        }

        private void OnEnable()
        {
            // Subscribe to game events for automatic sound playback.
            GameEvents.OnCoinCollected += HandleCoinCollected;
            GameEvents.OnPowerUpCollected += HandlePowerUpCollected;
            GameEvents.OnPowerUpExpired += HandlePowerUpExpired;
            GameEvents.OnGameOver += HandleGameOver;
            GameEvents.OnShieldConsumed += HandleShieldConsumed;
            GameEvents.OnGameStarted += HandleGameStarted;
        }

        private void OnDisable()
        {
            GameEvents.OnCoinCollected -= HandleCoinCollected;
            GameEvents.OnPowerUpCollected -= HandlePowerUpCollected;
            GameEvents.OnPowerUpExpired -= HandlePowerUpExpired;
            GameEvents.OnGameOver -= HandleGameOver;
            GameEvents.OnShieldConsumed -= HandleShieldConsumed;
            GameEvents.OnGameStarted -= HandleGameStarted;
        }

        // ---------------------------------------------------------------
        // Initialization
        // ---------------------------------------------------------------

        /// <summary>
        /// Creates a pool of AudioSource components for SFX playback.
        /// All sources are created on this GameObject.
        /// </summary>
        private void CreateSFXPool()
        {
            sfxSources = new AudioSource[sfxPoolSize];

            for (int i = 0; i < sfxPoolSize; i++)
            {
                AudioSource source = gameObject.AddComponent<AudioSource>();
                source.playOnAwake = false;
                source.loop = false;
                sfxSources[i] = source;
            }

            Debug.Log($"[AudioManager] Created SFX pool with " +
                      $"{sfxPoolSize} sources.");
        }

        /// <summary>
        /// Creates two AudioSource components for music playback
        /// (one active, one for crossfading).
        /// </summary>
        private void CreateMusicSources()
        {
            musicSourceA = gameObject.AddComponent<AudioSource>();
            musicSourceA.playOnAwake = false;
            musicSourceA.loop = true;
            musicSourceA.priority = 0; // Highest priority for music.

            musicSourceB = gameObject.AddComponent<AudioSource>();
            musicSourceB.playOnAwake = false;
            musicSourceB.loop = true;
            musicSourceB.priority = 0;

            activeMusicSource = musicSourceA;
        }

        // ---------------------------------------------------------------
        // SFX Playback
        // ---------------------------------------------------------------

        /// <summary>
        /// Plays a sound effect by SoundType. Looks up the clip
        /// in the AudioConfig and plays it on an available pooled source.
        /// </summary>
        /// <param name="type">The type of sound to play.</param>
        public void PlaySFX(SoundType type)
        {
            if (isMuted) return;
            if (audioConfig == null) return;

            SoundEntry entry = audioConfig.GetSound(type);
            if (entry == null || entry.clip == null) return;

            PlaySFX(entry.clip, entry.volume, entry.pitchVariation);
        }

        /// <summary>
        /// Plays an AudioClip as a sound effect on an available
        /// pooled AudioSource. If all sources are busy, the oldest
        /// one is interrupted.
        /// </summary>
        /// <param name="clip">The audio clip to play.</param>
        /// <param name="volume">Per-sound volume (0-1).</param>
        /// <param name="pitchVariation">Random pitch offset range.</param>
        public void PlaySFX(AudioClip clip, float volume = 1f,
                            float pitchVariation = 0f)
        {
            if (isMuted || clip == null) return;

            AudioSource source = GetAvailableSFXSource();
            if (source == null) return;

            source.clip = clip;

            // Calculate final volume: per-sound * SFX channel * master.
            source.volume = volume * sfxVolume * masterVolume;

            // Apply pitch variation for natural-sounding repetition.
            if (pitchVariation > 0f)
            {
                source.pitch = 1f + Random.Range(
                    -pitchVariation, pitchVariation
                );
            }
            else
            {
                source.pitch = 1f;
            }

            source.Play();
        }

        /// <summary>
        /// Finds an AudioSource in the pool that is not currently playing.
        /// If all are busy, returns the one that has been playing longest
        /// (it will be interrupted).
        /// </summary>
        private AudioSource GetAvailableSFXSource()
        {
            // First pass: find a source that is not playing.
            for (int i = 0; i < sfxSources.Length; i++)
            {
                if (!sfxSources[i].isPlaying)
                {
                    return sfxSources[i];
                }
            }

            // All sources are busy. Find the one with the least
            // remaining time (closest to finishing).
            AudioSource oldest = sfxSources[0];
            float leastTimeRemaining = float.MaxValue;

            for (int i = 0; i < sfxSources.Length; i++)
            {
                float remaining = sfxSources[i].clip.length
                    - sfxSources[i].time;

                if (remaining < leastTimeRemaining)
                {
                    leastTimeRemaining = remaining;
                    oldest = sfxSources[i];
                }
            }

            return oldest;
        }

        // ---------------------------------------------------------------
        // Music Playback
        // ---------------------------------------------------------------

        /// <summary>
        /// Starts playing a music track by index. If music is already
        /// playing, crossfades to the new track.
        /// </summary>
        /// <param name="trackIndex">Index into AudioConfig.musicTracks.</param>
        public void PlayMusic(int trackIndex)
        {
            if (audioConfig == null) return;
            if (audioConfig.musicTracks == null) return;
            if (trackIndex < 0 || trackIndex >= audioConfig.musicTracks.Length)
                return;

            MusicTrack track = audioConfig.musicTracks[trackIndex];
            if (track.clip == null) return;

            currentTrackIndex = trackIndex;

            // Determine which source to use for the new track.
            AudioSource newSource = (activeMusicSource == musicSourceA)
                ? musicSourceB
                : musicSourceA;

            newSource.clip = track.clip;
            newSource.volume = 0f; // Start silent for crossfade.
            newSource.Play();

            // Calculate target volume.
            float targetVolume = track.volume * musicVolume * masterVolume;
            if (isMuted) targetVolume = 0f;

            // Start crossfade.
            if (crossfadeCoroutine != null)
            {
                StopCoroutine(crossfadeCoroutine);
            }

            crossfadeCoroutine = StartCoroutine(
                CrossfadeMusic(activeMusicSource, newSource,
                               targetVolume, crossfadeDuration)
            );

            activeMusicSource = newSource;

            Debug.Log($"[AudioManager] Playing music: {track.trackName}");
        }

        /// <summary>
        /// Crossfades between two music AudioSources. The old source
        /// fades out while the new source fades in simultaneously.
        /// </summary>
        private IEnumerator CrossfadeMusic(
            AudioSource fadeOut,
            AudioSource fadeIn,
            float targetVolume,
            float duration)
        {
            float elapsed = 0f;
            float startVolume = fadeOut.volume;

            while (elapsed < duration)
            {
                elapsed += Time.unscaledDeltaTime;
                float t = Mathf.Clamp01(elapsed / duration);

                // Smooth step for pleasant-sounding fade.
                float easedT = Mathf.SmoothStep(0f, 1f, t);

                fadeOut.volume = Mathf.Lerp(startVolume, 0f, easedT);
                fadeIn.volume = Mathf.Lerp(0f, targetVolume, easedT);

                yield return null;
            }

            // Ensure final values.
            fadeOut.volume = 0f;
            fadeOut.Stop();
            fadeIn.volume = targetVolume;

            crossfadeCoroutine = null;
        }

        /// <summary>
        /// Stops all music playback with a fade out.
        /// </summary>
        public void StopMusic(float fadeOutDuration = 1f)
        {
            if (crossfadeCoroutine != null)
            {
                StopCoroutine(crossfadeCoroutine);
            }

            crossfadeCoroutine = StartCoroutine(
                FadeOutMusic(activeMusicSource, fadeOutDuration)
            );
        }

        private IEnumerator FadeOutMusic(AudioSource source, float duration)
        {
            float startVolume = source.volume;
            float elapsed = 0f;

            while (elapsed < duration)
            {
                elapsed += Time.unscaledDeltaTime;
                source.volume = Mathf.Lerp(
                    startVolume, 0f,
                    elapsed / duration
                );
                yield return null;
            }

            source.volume = 0f;
            source.Stop();
            crossfadeCoroutine = null;
        }

        // ---------------------------------------------------------------
        // Volume Controls
        // ---------------------------------------------------------------

        /// <summary>
        /// Sets the master volume (affects both music and SFX).
        /// </summary>
        /// <param name="volume">Volume from 0 (silent) to 1 (full).</param>
        public void SetMasterVolume(float volume)
        {
            masterVolume = Mathf.Clamp01(volume);
            UpdateMusicVolume();
            SaveVolumeSettings();
        }

        /// <summary>
        /// Sets the music volume.
        /// </summary>
        /// <param name="volume">Volume from 0 to 1.</param>
        public void SetMusicVolume(float volume)
        {
            musicVolume = Mathf.Clamp01(volume);
            UpdateMusicVolume();
            SaveVolumeSettings();
        }

        /// <summary>
        /// Sets the SFX volume. Applies to the next sound played
        /// (does not affect currently playing sounds).
        /// </summary>
        /// <param name="volume">Volume from 0 to 1.</param>
        public void SetSFXVolume(float volume)
        {
            sfxVolume = Mathf.Clamp01(volume);
            SaveVolumeSettings();
        }

        /// <summary>
        /// Toggles mute on/off. When muted, all audio is silent
        /// but music continues playing (so unmmuting resumes seamlessly).
        /// </summary>
        public void ToggleMute()
        {
            SetMuted(!isMuted);
        }

        /// <summary>
        /// Sets the mute state directly.
        /// </summary>
        public void SetMuted(bool muted)
        {
            isMuted = muted;
            UpdateMusicVolume();
            SaveVolumeSettings();
        }

        /// <summary>
        /// Applies the current volume settings to the active music source.
        /// Called whenever any volume slider changes.
        /// </summary>
        private void UpdateMusicVolume()
        {
            if (activeMusicSource == null || !activeMusicSource.isPlaying)
                return;

            if (isMuted)
            {
                activeMusicSource.volume = 0f;
            }
            else if (currentTrackIndex >= 0
                     && currentTrackIndex < audioConfig.musicTracks.Length)
            {
                float trackVolume =
                    audioConfig.musicTracks[currentTrackIndex].volume;
                activeMusicSource.volume =
                    trackVolume * musicVolume * masterVolume;
            }
        }

        // ---------------------------------------------------------------
        // Volume Persistence
        // ---------------------------------------------------------------

        /// <summary>
        /// Saves all volume settings to PlayerPrefs.
        /// </summary>
        private void SaveVolumeSettings()
        {
            PlayerPrefs.SetFloat(MASTER_VOL_KEY, masterVolume);
            PlayerPrefs.SetFloat(MUSIC_VOL_KEY, musicVolume);
            PlayerPrefs.SetFloat(SFX_VOL_KEY, sfxVolume);
            PlayerPrefs.SetInt(MUTE_KEY, isMuted ? 1 : 0);
            PlayerPrefs.Save();
        }

        /// <summary>
        /// Loads volume settings from PlayerPrefs, falling back to
        /// defaults if no saved values exist.
        /// </summary>
        private void LoadVolumeSettings()
        {
            masterVolume = PlayerPrefs.GetFloat(
                MASTER_VOL_KEY, defaultMasterVolume
            );
            musicVolume = PlayerPrefs.GetFloat(
                MUSIC_VOL_KEY, defaultMusicVolume
            );
            sfxVolume = PlayerPrefs.GetFloat(
                SFX_VOL_KEY, defaultSfxVolume
            );
            isMuted = PlayerPrefs.GetInt(MUTE_KEY, 0) == 1;

            Debug.Log($"[AudioManager] Loaded audio settings - " +
                      $"Master: {masterVolume:F2}, " +
                      $"Music: {musicVolume:F2}, " +
                      $"SFX: {sfxVolume:F2}, " +
                      $"Muted: {isMuted}");
        }

        // ---------------------------------------------------------------
        // Event Handlers (Automatic Sound Playback)
        // ---------------------------------------------------------------

        /// <summary>
        /// Plays the coin collection sound.
        /// </summary>
        private void HandleCoinCollected(int value)
        {
            PlaySFX(SoundType.Collect);
        }

        /// <summary>
        /// Plays the power-up pickup sound.
        /// </summary>
        private void HandlePowerUpCollected(PowerUpType type, float duration)
        {
            PlaySFX(SoundType.PowerUp);
        }

        /// <summary>
        /// Plays the power-up expiration sound.
        /// </summary>
        private void HandlePowerUpExpired(PowerUpType type)
        {
            PlaySFX(SoundType.PowerUpExpire);
        }

        /// <summary>
        /// Plays the death sound.
        /// </summary>
        private void HandleGameOver()
        {
            PlaySFX(SoundType.Death);
        }

        /// <summary>
        /// Plays the shield break sound.
        /// </summary>
        private void HandleShieldConsumed()
        {
            PlaySFX(SoundType.ShieldBreak);
        }

        /// <summary>
        /// Resets audio state for a new game.
        /// Optionally switch to gameplay music.
        /// </summary>
        private void HandleGameStarted()
        {
            // If you have different music for menu vs. gameplay,
            // crossfade to the gameplay track here.
            // PlayMusic(1); // Index 1 = gameplay track
        }

        // ---------------------------------------------------------------
        // Public API (Getters)
        // ---------------------------------------------------------------

        /// <summary>Returns the current master volume (0-1).</summary>
        public float GetMasterVolume() => masterVolume;

        /// <summary>Returns the current music volume (0-1).</summary>
        public float GetMusicVolume() => musicVolume;

        /// <summary>Returns the current SFX volume (0-1).</summary>
        public float GetSFXVolume() => sfxVolume;

        /// <summary>Returns whether audio is muted.</summary>
        public bool IsMuted() => isMuted;
    }
}

Playing Sounds from Game Code

While the AudioManager automatically handles many sounds through event subscriptions, some sounds need to be triggered directly from gameplay scripts. Here is how other systems play sounds:

Playing sounds from any scriptC#
using InfiniteRunner.Audio;

// Play a sound by type (recommended approach):
AudioManager.Instance?.PlaySFX(SoundType.Jump);
AudioManager.Instance?.PlaySFX(SoundType.LaneChange);
AudioManager.Instance?.PlaySFX(SoundType.ButtonClick);

// Play a specific AudioClip with custom volume:
public AudioClip customClip;
AudioManager.Instance?.PlaySFX(customClip, 0.7f);

// Play a clip with pitch variation:
AudioManager.Instance?.PlaySFX(customClip, 1f, 0.1f);

PlayerController Integration Example

PlayerController.cs (audio excerpt)C#
using InfiniteRunner.Audio;

// In the jump method:
private void PerformJump()
{
    // ... jump physics ...
    AudioManager.Instance?.PlaySFX(SoundType.Jump);
}

// In the slide method:
private void PerformSlide()
{
    // ... slide logic ...
    AudioManager.Instance?.PlaySFX(SoundType.Slide);
}

// In the lane change method:
private void ChangeLane(int direction)
{
    // ... lane change logic ...
    AudioManager.Instance?.PlaySFX(SoundType.LaneChange);
}

UI Button Sound

UIButtonSound.csC#
using UnityEngine;
using UnityEngine.UI;
using InfiniteRunner.Audio;

namespace InfiniteRunner.UI
{
    /// <summary>
    /// Plays a click sound when a UI button is pressed.
    /// Attach this to any Button that should make a sound.
    /// </summary>
    [RequireComponent(typeof(Button))]
    public class UIButtonSound : MonoBehaviour
    {
        private void Awake()
        {
            GetComponent<Button>().onClick.AddListener(PlayClickSound);
        }

        private void OnDestroy()
        {
            GetComponent<Button>().onClick.RemoveListener(PlayClickSound);
        }

        private void PlayClickSound()
        {
            AudioManager.Instance?.PlaySFX(SoundType.ButtonClick);
        }
    }
}
The Null-Conditional Operator (?.)

We use AudioManager.Instance?.PlaySFX() with the ?. operator. This means "if Instance is not null, call PlaySFX." If the AudioManager has not been set up yet (maybe in a test scene), the call is simply skipped instead of throwing a NullReferenceException. This is a defensive programming pattern that makes your code more robust.

Volume Settings UI

Players need the ability to adjust volume levels. Here is a script that connects UI sliders to the AudioManager. You would add this to your Settings panel.

AudioSettingsUI.csC#
using UnityEngine;
using UnityEngine.UI;
using InfiniteRunner.Audio;

namespace InfiniteRunner.UI
{
    /// <summary>
    /// Connects volume sliders and a mute toggle to the AudioManager.
    /// Place this on your Settings panel and wire up the UI elements.
    /// </summary>
    public class AudioSettingsUI : MonoBehaviour
    {
        [Header("Volume Sliders")]
        [Tooltip("Slider for master volume (0-1).")]
        [SerializeField] private Slider masterSlider;

        [Tooltip("Slider for music volume (0-1).")]
        [SerializeField] private Slider musicSlider;

        [Tooltip("Slider for SFX volume (0-1).")]
        [SerializeField] private Slider sfxSlider;

        [Header("Mute Toggle")]
        [Tooltip("Toggle button for muting all audio.")]
        [SerializeField] private Toggle muteToggle;

        private void OnEnable()
        {
            // When the settings panel opens, set the sliders to
            // match the current audio settings.
            if (AudioManager.Instance == null) return;

            if (masterSlider != null)
            {
                masterSlider.value = AudioManager.Instance.GetMasterVolume();
                masterSlider.onValueChanged.AddListener(OnMasterVolumeChanged);
            }

            if (musicSlider != null)
            {
                musicSlider.value = AudioManager.Instance.GetMusicVolume();
                musicSlider.onValueChanged.AddListener(OnMusicVolumeChanged);
            }

            if (sfxSlider != null)
            {
                sfxSlider.value = AudioManager.Instance.GetSFXVolume();
                sfxSlider.onValueChanged.AddListener(OnSfxVolumeChanged);
            }

            if (muteToggle != null)
            {
                muteToggle.isOn = AudioManager.Instance.IsMuted();
                muteToggle.onValueChanged.AddListener(OnMuteToggled);
            }
        }

        private void OnDisable()
        {
            // Remove listeners when the panel is hidden to prevent
            // memory leaks and duplicate subscriptions.
            if (masterSlider != null)
                masterSlider.onValueChanged.RemoveListener(OnMasterVolumeChanged);

            if (musicSlider != null)
                musicSlider.onValueChanged.RemoveListener(OnMusicVolumeChanged);

            if (sfxSlider != null)
                sfxSlider.onValueChanged.RemoveListener(OnSfxVolumeChanged);

            if (muteToggle != null)
                muteToggle.onValueChanged.RemoveListener(OnMuteToggled);
        }

        // ---------------------------------------------------------------
        // Slider Handlers
        // ---------------------------------------------------------------

        private void OnMasterVolumeChanged(float value)
        {
            AudioManager.Instance?.SetMasterVolume(value);
        }

        private void OnMusicVolumeChanged(float value)
        {
            AudioManager.Instance?.SetMusicVolume(value);
        }

        private void OnSfxVolumeChanged(float value)
        {
            AudioManager.Instance?.SetSFXVolume(value);

            // Play a test sound so the player can hear the new level.
            AudioManager.Instance?.PlaySFX(SoundType.Collect);
        }

        private void OnMuteToggled(bool muted)
        {
            AudioManager.Instance?.SetMuted(muted);
        }
    }
}
Test Sound on SFX Slider

Notice that when the SFX slider changes, we play a test sound (SoundType.Collect). This gives the player immediate feedback about the volume level they are setting. Without this, they would have to go back to the game to hear the effect of their change. This is a small UX touch found in most professional games.

Where to Find Free Game Audio

You do not need to create your own sound effects and music. There are many excellent free resources available:

Sound Effects

  • Freesound.org — Massive library of user-contributed sounds. Many are CC0 (public domain). Always check the individual license.
  • Kenney.nl — High-quality game assets including sound effects, all CC0. The "Interface Sounds" and "Impact Sounds" packs are perfect for our game.
  • sfxr.me — A browser-based tool for generating retro-style sound effects. Great for coin pickups, jumps, and UI clicks. Click "Pickup/Coin" and randomize until you find one you like.

Background Music

  • Incompetech — Kevin MacLeod's royalty-free music library. Requires attribution. Huge variety of genres and tempos.
  • OpenGameArt.org — Community-contributed game assets including music. Various licenses (check each one).
  • Unity Asset Store — Search for "free music" or "free SFX." Many publishers offer free sample packs.
Always Check the License

Free does not always mean "use however you want." Some licenses require attribution (credit the author), some prohibit commercial use, and some require sharing your work under the same license. Always read the license for every asset you use. CC0 (Creative Commons Zero) is the safest — it means the creator waived all rights and you can use it for anything. CC-BY means you must credit the creator.

Audio Import Settings in Unity

How you import audio files affects both quality and performance. Here are recommended settings:

Sound Effects

  1. Select the audio file in the Project window.
  2. In the Inspector, set Load Type to Decompress On Load. SFX are short, so keeping them decompressed in memory is fine and avoids CPU spikes.
  3. Set Compression Format to Vorbis with quality around 70%.
  4. Uncheck Preload Audio Data if you have many SFX and want to save memory at startup.

Music

  1. Select the music file.
  2. Set Load Type to Streaming. Music files are large, and streaming reads them from disk in small chunks instead of loading the entire file into memory.
  3. Set Compression Format to Vorbis with quality around 50-70%.
Mono vs. Stereo

For most mobile games, SFX should be mono (single channel). Mono files are half the size of stereo and for short game sounds, the player will not notice the difference. Set Force To Mono on SFX imports. Keep music as stereo since it benefits from the wider sound stage.

Chapter Summary

In this chapter, you built a complete audio system for the infinite runner:

  • SoundType enum — Type-safe identifiers for every sound effect in the game.
  • AudioConfig ScriptableObject — Data-driven mapping from SoundType to AudioClip, with per-sound volume and pitch variation settings.
  • AudioManager.cs — Singleton manager with:
    • Pooled AudioSources for SFX (no runtime allocation).
    • Dual AudioSources for music crossfading.
    • Master, music, and SFX volume controls.
    • Mute toggle.
    • Volume persistence via PlayerPrefs.
    • Event-driven automatic sound playback for game events.
  • UIButtonSound.cs — Simple component for adding click sounds to UI buttons.
  • AudioSettingsUI.cs — UI panel with volume sliders and mute toggle.
  • Audio asset sources — Where to find free, high-quality sound effects and music.
  • Import settings — Proper Unity import configuration for SFX (Decompress On Load) and music (Streaming).

Sound transforms a silent tech demo into a game that feels alive. In the next chapter, we will add particle effects to create visual flair for coin collection, power-up activation, and obstacle impacts.

Save Your Progress

Audio system done. Commit and push.

git add .
git commit -m "Add audio system with SFX pooling and music crossfade"
git push

Short and sweet. You know the workflow by now.