menu_bookDocumentation

Building a Networked Radio System with 3D Audio and Sync

calendar_today Jun 16, 2026 schedule ~3 min read person PatrickJr verified 50

Building a functional, interactive, and multiplayer-compatible radio system in s&box requires coordinating networked replication, local 3D audio handles, external HTTP streaming, and user interactions.

This entry documents the architectural patterns, networking flow, and gotchas identified when implementing such a system.

1. Architecture and Design

A robust radio system splits concerns into:
  • RadioStation (Data Container): A class defining station parameters: metadata (ID, Name, Frequency, Genre), internet stream URLs (StreamUrl, MetaDataUrl), and collections of local SoundEvent assets for music playlists, announcements, commercials, and news.
  • RadioComponent (Component): A component attached to the radio GameObject. It processes update ticks, manages live local sound handles, polls web APIs for metadata, and exposes interaction methods to the player.

2. Networking and Synchronization

To ensure all players in a lobby see and hear the same station state, the core properties of the radio must be synchronized using [Sync] attributes combined with Change callbacks:
CSHARP
[Sync, Change( nameof( OnPowerChanged ) )] public bool IsPowered { get; set; }
[Sync, Change( nameof( OnStationChanged ) )] public int StationIndex { get; set; } = -1;
[Sync, Change( nameof( OnVolumeChanged ) )] public float Volume { get; set; } = 0.5f;
Sync Callback Flow
  1. State Mutation: The owner of the GameObject modifies a synced property (e.g. StationIndex).
  2. Network Replication: The updated value is replicated to all remote clients.
  3. Local Execution: On replication, each client automatically fires the Change callback (e.g., OnStationChanged(int oldValue, int newValue)).
  4. Audio Handling: Inside the callback, the client executes local audio operations (stopping old audio, playing static/tuning effects, initializing the new station stream or track).
Pitfall: Interaction Ownership
Only the owner (authority) of a GameObject can change [Sync] properties and have those changes replicate. If a guest client interacts with the radio directly and modifies these properties, the change remains local and is eventually overwritten by the network.
  • Solution: Wrap interaction logic in [Authority] RPC methods. When a client interacts, they invoke the RPC to request that the host/owner change the state:
CSHARP
public override void OnInteract( Sandbox.PlayerController player )
{
    TogglePowerRpc();
}

[Authority]
private void TogglePowerRpc()
{
    IsPowered = !IsPowered;
}

3. Audio Playback Mechanics

S&box supports two distinct ways to output radio audio:
  1. Live Web Streams: Handled using MusicPlayer.PlayUrl(streamUrl). Since MusicPlayer is an independent audio player, its spatial position (_streamPlayer.Position = WorldPosition) must be manually updated in OnUpdate() every frame.
  2. Local SoundEvents: Handled using Sound.Play(soundEvent, position). The returned SoundHandle can automatically track the parent GameObject's movements by setting:
CSHARP
handle.Parent = GameObject;
handle.FollowParent = true;
Pitfall: Playlist and Timer Desync
For local playlists and personality events (such as DJ announcements or commercials) triggered by elapsed time timers, running logic locally on each client causes players to hear different tracks and announcements at different times.
  • Solution: The host should act as the authority for track progression, synchronizing the current track index and playback start time over the network, allowing remote clients to seek to the matching time.
Pitfall: Metadata Polling Spam
If every client runs an asynchronous loop to fetch the live stream's metadata URL, the stream provider will be spammed with HTTP requests.

Minimal Implementation Example

CSHARP
using Sandbox;
using System.Collections.Generic;

public sealed class NetworkedRadio : Component, Component.IInteractable
{
    [Property] public List<RadioStationData> Stations { get; set; } = new();
    [Property] public float MaxDistance { get; set; } = 1200f;

    [Sync, Change( nameof( OnPowerChanged ) )] public bool IsPowered { get; set; }
    [Sync, Change( nameof( OnStationChanged ) )] public int StationIndex { get; set; } = 0;

    private SoundHandle _localSoundHandle;
    private MusicPlayer _streamPlayer;

    private void OnPowerChanged( bool oldVal, bool newVal )
    {
        if ( newVal ) StartPlayback();
        else StopPlayback();
    }

    private void OnStationChanged( int oldVal, int newVal )
    {
        StopPlayback();
        StartPlayback();
    }

    public void OnInteract( Sandbox.PlayerController player )
    {
        TogglePowerRpc();
    }

    [Authority]
    private void TogglePowerRpc()
    {
        IsPowered = !IsPowered;
    }

    private void StartPlayback()
    {
        if ( !IsPowered || Stations.Count == 0 ) return;
        var station = Stations[StationIndex];

        if ( !string.IsNullOrEmpty( station.StreamUrl ) )
        {
            _streamPlayer = MusicPlayer.PlayUrl( station.StreamUrl );
            if ( _streamPlayer != null )
            {
                _streamPlayer.Position = WorldPosition;
                _streamPlayer.Distance = MaxDistance;
            }
        }
        else if ( station.Playlist.Count > 0 )
        {
            var soundEvent = station.Playlist[0];
            _localSoundHandle = Sound.Play( soundEvent, WorldPosition );
            if ( _localSoundHandle != null )
            {
                _localSoundHandle.Parent = GameObject;
                _localSoundHandle.FollowParent = true;
                _localSoundHandle.Distance = MaxDistance;
            }
        }
    }

    private void StopPlayback()
    {
        _streamPlayer?.Stop();
        _streamPlayer?.Dispose();
        _streamPlayer = null;

        _localSoundHandle?.Stop();
        _localSoundHandle = null;
    }

    protected override void OnUpdate()
    {
        if ( _streamPlayer != null )
        {
            _streamPlayer.Position = WorldPosition;
        }
    }

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

public class RadioStationData
{
    public string Name { get; set; }
    public string StreamUrl { get; set; }
    public List<SoundEvent> Playlist { get; set; } = new();
}
Was this helpful?