terminalCode Example

Trigger-Based Background Music Crossfade Framework

calendar_today May 10, 2026 schedule ~2 min read person rater193 verified 50

This pattern replaces area SoundBoxComponent music with a small local-client music framework. Each zone requests a target SoundEvent, while one controller crossfades the currently playing track into the new one.

Use this when you want music to change by area without multiple soundboxes overlapping or fighting. The framework is split into two scripts:

The music is intentionally local-client driven. In multiplayer, each client should hear music based on their own local player's trigger state, so trigger code ignores proxy players.
CSHARP
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

public sealed class BackgroundMusicController : Component
{
	public static BackgroundMusicController Local { get; private set; }

	[Property] public SoundEvent DefaultSound { get; set; }
	[Property] public SoundEvent TargetSound { get; set; }
	[Property] public float FadeDuration { get; set; } = 2f;
	[Property] public float MasterVolume { get; set; } = 1f;
	[Property] public bool PlayDefaultOnStart { get; set; } = true;
	[Property] public bool Force2d { get; set; } = true;

	private readonly Dictionary<Component, MusicRequest> requests = new();
	private MusicLayer currentLayer;
	private MusicLayer fadingLayer;
	private string currentSoundKey = string.Empty;
	private string targetSoundKey = string.Empty;
	private float targetVolume = 1f;
	private float fadeElapsed;
	private int requestSequence;

	protected override void OnEnabled()
	{
		Local = this;
	}

	protected override void OnDisabled()
	{
		if ( Local == this )
			Local = null;

		StopAllMusic( 0.25f );
	}

	protected override void OnDestroy()
	{
		if ( Local == this )
			Local = null;

		StopAllMusic( 0.25f );
	}

	protected override void OnStart()
	{
		Local = this;

		if ( PlayDefaultOnStart && DefaultSound.IsValid() && !TargetSound.IsValid() )
		{
			TargetSound = DefaultSound;
		}

		ApplyTargetSound();
	}

	protected override void OnUpdate()
	{
		ApplyTargetSound();
		UpdateFade();
	}

	public static BackgroundMusicController GetOrCreate( Scene scene )
	{
		if ( Local.IsValid() && Local.Scene == scene )
			return Local;

		var existing = scene?.GetAllComponents<BackgroundMusicController>().FirstOrDefault();
		if ( existing.IsValid() )
		{
			Local = existing;
			return existing;
		}

		if ( scene == null )
			return null;

		var controllerObject = new GameObject( scene, true, "Background Music Controller" );
		Local = controllerObject.Components.Create<BackgroundMusicController>();
		return Local;
	}

	public void RequestMusic( Component source, SoundEvent sound, int priority = 0, float volume = 1f )
	{
		if ( source == null || !sound.IsValid() )
			return;

		requests[source] = new MusicRequest( sound, priority, MathF.Max( 0f, volume ), ++requestSequence );
		RefreshRequestedTarget();
	}

	public void ClearRequest( Component source )
	{
		if ( source == null )
			return;

		if ( requests.Remove( source ) )
		{
			RefreshRequestedTarget();
		}
	}

	public void SetTarget( SoundEvent sound, float volume = 1f )
	{
		TargetSound = sound;
		targetVolume = MathF.Max( 0f, volume );
		ApplyTargetSound( forceVolumeRefresh: true );
	}

	private void RefreshRequestedTarget()
	{
		var bestRequest = requests
			.Where( entry => entry.Key.IsValid() && entry.Value.Sound.IsValid() )
			.Select( entry => entry.Value )
			.OrderByDescending( request => request.Priority )
			.ThenByDescending( request => request.Sequence )
			.FirstOrDefault();

		if ( bestRequest.Sound.IsValid() )
		{
			SetTarget( bestRequest.Sound, bestRequest.Volume );
			return;
		}

		SetTarget( DefaultSound, 1f );
	}

	private void ApplyTargetSound( bool forceVolumeRefresh = false )
	{
		var newTargetKey = GetSoundKey( TargetSound );
		if ( newTargetKey == targetSoundKey && !forceVolumeRefresh )
			return;

		targetSoundKey = newTargetKey;

		if ( string.IsNullOrWhiteSpace( targetSoundKey ) || !TargetSound.IsValid() )
		{
			FadeOutCurrent();
			return;
		}

		if ( currentSoundKey == targetSoundKey && currentLayer.Handle.IsValid() )
		{
			currentLayer.TargetVolume = GetTargetVolume();
			return;
		}

		StartTransition( TargetSound, targetSoundKey, GetTargetVolume() );
	}

	private void StartTransition( SoundEvent sound, string soundKey, float volume )
	{
		if ( fadingLayer.Handle.IsValid() )
		{
			fadingLayer.Handle.Stop( FadeDuration );
		}

		fadingLayer = currentLayer;
		fadingLayer.StartVolume = fadingLayer.Handle.IsValid() ? fadingLayer.Handle.Volume : 0f;
		fadingLayer.TargetVolume = 0f;

		currentLayer = StartLayer( sound, volume );
		currentSoundKey = soundKey;
		fadeElapsed = 0f;
	}

	private MusicLayer StartLayer( SoundEvent sound, float volume )
	{
		var handle = Sound.Play( sound );
		if ( handle.IsValid() )
		{
			handle.Volume = 0f;
			handle.Name = $"Background Music - {sound.ResourceName}";

			if ( Force2d )
			{
				handle.ListenLocal = true;
				handle.SpacialBlend = 0f;
				handle.DistanceAttenuation = false;
				handle.Occlusion = false;
				handle.AirAbsorption = false;
				handle.Transmission = false;
			}
		}

		return new MusicLayer
		{
			Handle = handle,
			StartVolume = 0f,
			TargetVolume = volume
		};
	}

	private void FadeOutCurrent()
	{
		if ( currentLayer.Handle.IsValid() )
		{
			currentLayer.Handle.Stop( FadeDuration );
		}

		currentLayer = default;
		currentSoundKey = string.Empty;
	}

	private void StopAllMusic( float fadeTime )
	{
		if ( currentLayer.Handle.IsValid() )
			currentLayer.Handle.Stop( fadeTime );

		if ( fadingLayer.Handle.IsValid() )
			fadingLayer.Handle.Stop( fadeTime );

		currentLayer = default;
		fadingLayer = default;
		currentSoundKey = string.Empty;
		targetSoundKey = string.Empty;
	}

	private void UpdateFade()
	{
		var duration = MathF.Max( 0.01f, FadeDuration );
		fadeElapsed += Time.Delta;
		var progress = Math.Clamp( fadeElapsed / duration, 0f, 1f );
		var easedProgress = progress * progress * (3f - (2f * progress));

		if ( currentLayer.Handle.IsValid() )
		{
			currentLayer.TargetVolume = GetTargetVolume();
			currentLayer.Handle.Volume = MathX.Lerp( currentLayer.StartVolume, currentLayer.TargetVolume, easedProgress );
		}

		if ( fadingLayer.Handle.IsValid() )
		{
			fadingLayer.Handle.Volume = MathX.Lerp( fadingLayer.StartVolume, 0f, easedProgress );
		}

		if ( progress < 1f )
			return;

		if ( currentLayer.Handle.IsValid() )
		{
			currentLayer.Handle.Volume = currentLayer.TargetVolume;
		}

		if ( fadingLayer.Handle.IsValid() )
		{
			fadingLayer.Handle.Stop();
		}

		fadingLayer = default;
	}

	private float GetTargetVolume()
	{
		return MathF.Max( 0f, targetVolume * MasterVolume );
	}

	private static string GetSoundKey( SoundEvent sound )
	{
		if ( !sound.IsValid() )
			return string.Empty;

		if ( !string.IsNullOrWhiteSpace( sound.ResourcePath ) )
			return sound.ResourcePath;

		return sound.ResourceName ?? string.Empty;
	}

	private readonly record struct MusicRequest( SoundEvent Sound, int Priority, float Volume, int Sequence );

	private struct MusicLayer
	{
		public SoundHandle Handle;
		public float StartVolume;
		public float TargetVolume;
	}
}

public sealed class MusicZoneTrigger : Component, Component.ITriggerListener
{
	[Property] public SoundEvent Music { get; set; }
	[Property] public BackgroundMusicController Controller { get; set; }
	[Property] public int Priority { get; set; }
	[Property] public float Volume { get; set; } = 1f;
	[Property] public bool ClearMusicWhenExited { get; set; } = true;

	private readonly Dictionary<GameObject, int> touchingLocalPlayers = new();

	protected override void OnStart()
	{
		EnsureTriggerCollider();
	}

	protected override void OnDisabled()
	{
		ClearMusicRequest();
		touchingLocalPlayers.Clear();
	}

	protected override void OnDestroy()
	{
		ClearMusicRequest();
	}

	public void OnTriggerEnter( Collider other )
	{
		var player = GetLocalPlayer( other );
		if ( player == null )
			return;

		var playerObject = player.GameObject;
		touchingLocalPlayers.TryGetValue( playerObject, out var touchCount );
		touchingLocalPlayers[playerObject] = touchCount + 1;

		if ( touchCount == 0 )
		{
			RequestMusic();
		}
	}

	public void OnTriggerExit( Collider other )
	{
		var player = GetLocalPlayer( other );
		if ( player == null )
			return;

		var playerObject = player.GameObject;
		if ( !touchingLocalPlayers.TryGetValue( playerObject, out var touchCount ) )
			return;

		touchCount--;
		if ( touchCount > 0 )
		{
			touchingLocalPlayers[playerObject] = touchCount;
			return;
		}

		touchingLocalPlayers.Remove( playerObject );

		if ( touchingLocalPlayers.Count == 0 && ClearMusicWhenExited )
		{
			ClearMusicRequest();
		}
	}

	public void RequestMusic()
	{
		if ( !Music.IsValid() )
			return;

		ResolveController()?.RequestMusic( this, Music, Priority, Volume );
	}

	public void ClearMusicRequest()
	{
		ResolveController()?.ClearRequest( this );
	}

	private BackgroundMusicController ResolveController()
	{
		if ( Controller.IsValid() )
			return Controller;

		Controller = BackgroundMusicController.GetOrCreate( Scene );
		return Controller;
	}

	private PlayerController GetLocalPlayer( Collider other )
	{
		if ( other == null )
			return null;

		var player = other.Components.Get<PlayerController>( FindMode.InAncestors );
		if ( player == null || player.IsProxy )
			return null;

		return player;
	}

	private void EnsureTriggerCollider()
	{
		var collider = Components.Get<Collider>( FindMode.EverythingInSelf );
		if ( collider == null )
		{
			var box = Components.Create<BoxCollider>();
			box.Scale = new Vector3( 512f, 512f, 256f );
			box.IsTrigger = true;
			return;
		}

		collider.IsTrigger = true;
	}
}

Example setup:

  1. Create one scene object named Background Music Controller and add BackgroundMusicController.
  2. Assign a looping .sound asset to DefaultSound.
  3. Set FadeDuration to 2 for a soft transition.
  4. Create trigger volumes for each area using BoxCollider, SphereCollider, or another collider with IsTrigger enabled.
  5. Add MusicZoneTrigger to each trigger object and assign the zone's Music SoundEvent.
  6. Use Priority when zones overlap. Higher priority wins; ties use the newest entered zone.
Notes:
  • Sound.Play returns a SoundHandle, so the controller can manually fade volumes each frame.
  • Force2d makes the music behave like local background music instead of a positional world sound.
  • The zone trigger resolves PlayerController with FindMode.InAncestors because s&box player setups often fire trigger events from child colliders.
  • Keep background tracks as looping SoundEvents. The code handles transitions, not looping behavior inside the audio file.
  • This pattern is also useful for ambience layers, combat music, indoor/outdoor music, or region-based soundtrack changes.
Was this helpful?