method: how to build it, in what order

Proximity voice chat: the built-in Sandbox.Voice component

✓ verified on 26.07.15a
lanes Writing gameplay, Audioposted updated

The complete method for wiring s&box's built-in Voice component into a networked game (push-to-talk, 3D positional playback, custom falloff curves, speaking indicators, and lip-sync), with no third-party voice SDK.

s&box ships a complete, networked microphone/voice system. You do not need to hand-roll Steam voice capture, Opus transmission, or 3D playback. It is one engine component: Sandbox.Voice.

1. The component surface

Voice (public class Voice : Component) records and transmits voice/microphone input to other players. Attach one per player object. The full API surface:

MemberMeaning
enum ActivateMode { AlwaysOn, PushToTalk, Manual }How the mic keys on.
ActivateMode ModeDefault AlwaysOn: you almost always want PushToTalk.
string PushToTalkInputInputAction name; default "voice". Held while Mode == PushToTalk.
bool WorldspacePlaybackDefault true: 3D positional playback at the component's WorldPosition, with occlusion. false = 2D ListenLocal.
bool LoopbackDefault false = don't play the sound of your own voice.
float VolumeDefault 1.
float DistanceDefault 15,000 units (about 381 m): max attenuation distance.
Curve FalloffNormalized distance 0..1 (where 1 = Distance) mapped to gain. The default curve is heavily front-loaded, wrong for a tight proximity bubble.
bool LipSync / SkinnedModelRenderer Renderer / MorphScale / MorphSmoothTimeDrives citizen viseme morphs from the live voice analysis. Safely no-ops if Renderer is invalid.
RealTimeSince LastPlayedResets to 0 on every received voice packet, the cleanest "is this player speaking" signal on a proxy.
bool IsRecording / bool IsListeningLocal capture state. IsListening is the preference-gated truth.
float AmplitudeLive loudness of the playback sound.
MixerHandle VoiceMixerTarget mixer; must descend from Mixer.Voice. A user "voice volume" slider hooks here.
float LaughterScore / IReadOnlyList<float> VisemesAnalysis outputs.
virtual IEnumerable<Connection> ExcludeFilter()Override to per-player MUTE (exclude connections from hearing you).
virtual bool ShouldHearVoice(Connection)Override to per-player DEAFEN (drop a speaker).

Networking is built in. The component transmits via [Rpc.Broadcast(NetFlags.OwnerOnly | NetFlags.UnreliableNoDelay)]: the owner records (Steam voice, 44.1 kHz, ~30 Hz read tick), compresses, and broadcasts; receiving peers decompress and play through a SoundStream. IsListening returns false when IsProxy, so a peer only ever transmits its own character. Application.IsHeadless short-circuits playback. A dedicated server is silent; voice relays peer-to-peer via the broadcast. A static singleRecorder ensures only one Voice records per client.

2. The wiring recipe

Two rules carry the whole feature:

Rule A: attach to the networked player object BEFORE NetworkSpawn. The component rides the replicated snapshot to every peer (the component-ordering law: create components, then NetworkSpawn). Attach only in the networked path. A solo character gets no Voice, giving zero mic activity in single-player with no null-guards needed:

snippet
var go  = Scene.CreateObject();
var chr = go.Components.Create<MyCharacter>();
if ( owner is not null )
{
    go.Components.Create<Voice>();     // BEFORE NetworkSpawn
    go.NetworkSpawn( owner );
}

Rule B: configure on every peer, not via replication. Playback config on a proxy comes from that peer's own OnStart, not the snapshot. Set Mode, Distance, Falloff, and PushToTalkInput on every peer:

snippet
protected override void OnStart()
{
    var voice = Components.Get<Voice>();
    if ( !voice.IsValid() ) return;   // solo mode - no Voice attached

    voice.Mode           = Voice.ActivateMode.PushToTalk;
    voice.PushToTalkInput = "Voice";
    voice.WorldspacePlayback = true;
    voice.LipSync        = true;
    voice.Distance       = VoiceMaxRangeM * 39.37f;   // metres → s&box units

    float nearRatio = VoiceNearRangeM / VoiceMaxRangeM;
    voice.Falloff = new Curve(
        new Curve.Frame( 0f,        1f, 0f, 0f ),
        new Curve.Frame( nearRatio, 1f, 0f, 0f ),
        new Curve.Frame( 1f,        0f, 0f, 0f ) );

    voice.Renderer = Components.Get<SkinnedModelRenderer>();
}

Curve(params Frame[]) and Curve.Frame(time, value, inTangent, outTangent) are public. Do not ship the default Falloff + Distance for proximity chat: 381 m of range with the stock curve gives a mushy, map-spanning whisper instead of a crisp local bubble.

3. Push-to-talk: three layers

  1. The engine user preference is already PTT by default. Preferences.VoiceMode (ConVar voip_mode, saved) defaults to PushToTalk. The IsListening logic: Disabled never transmits; PushToTalk always requires Input.Down(PushToTalkInput) regardless of component Mode; OpenMicrophone honors the component Mode.
  2. Bind the built-in "Voice" input action. Every project's ProjectSettings/Input.config typically ships a "Voice" action (often with KeyboardCode: None); the component's default PushToTalkInput = "voice" binds to it. Assign a free key and you're done. Verify it exists: not every project has it. If missing, add it to Input.config (editor restart required for input config changes to register).
  3. Set component Mode = PushToTalk anyway (belt-and-suspenders): keeps PTT gating even for a user whose saved voip_mode is OpenMicrophone. Without it, that user would hot-mic your game.

4. The speaking-indicator pattern

On a proxy, voice.LastPlayed < holdSeconds is a flicker-free "speaking" flag: packets arrive ~30/s while the mic is active, so a ~0.3 s hold window bridges inter-packet gaps and clears shortly after the talker stops:

snippet
var voice = chr.Components.Get<Voice>();
bool speaking = voice.IsValid() && voice.LastPlayed < 0.3f;

Anchor the tag off the networked transform, not an owner-only sim field. A proxy (the only character you tag) never runs the owner's OnFixedUpdate, so any private position field written there (and not [Sync]) freezes at the proxy's spawn value while the replicated GameObject.WorldPosition carries the real player. Anchor the tag off a frozen field and it strands at spawn while the model walks away. Read the character's WorldPosition (the same interpolated source the parented visual renders from). It also satisfies the read-interpolated-transforms rule. In a live two-peer session, a tag anchored to an unsynced owner field was observed hanging over the spawn area while the player model stood elsewhere; switching to WorldPosition fixed it immediately.

For a Razor name-tag overlay, fold the flag into BuildHash so the DOM rebuilds only on a state change, not per frame. Components.Get<Voice>() returns null on a non-networked character, so speaking = false gives the correct solo behavior for free.

5. Falloff radii as tuning constants

House the radii as named constants in metres with doc comments; convert once at emission (s&box units are inches; 1 m = 39.37 units):

snippet
public const float VoiceNearRangeM    = 8f;    // full-volume conversational core
public const float VoiceMaxRangeM     = 35f;   // silent at/beyond
public const float VoiceSpeakingHoldS = 0.3f;  // indicator hold window

These are live-tuning dials: the "how far can I hear them" feel can only be judged in a real two-peer session. Scale to your world footprint; the engine default (381 m) is effectively "everywhere" on a small map.

6. Extension points

  • Per-player mute: subclass (class MyVoice : Voice) overriding ExcludeFilter() to yield the connections that should not hear you (applied via Rpc.FilterExclude around the broadcast).
  • Per-player deafen: override ShouldHearVoice(Connection) to return false for speakers the local user muted.
  • Lip-sync: set Renderer to the peer's SkinnedModelRenderer. The engine drives citizen viseme morphs from the live analysis, holds the shape through packet gaps, and eases out after ~1 s. If the model lacks viseme morphs, LipSync safely no-ops.
  • Team voice / screen-space stereo: WorldspacePlayback = false puts the sound in screen space for team-left/right audio.
  • Occlusion: enabled by default in worldspace playback. Only avoidable via a subclass override if terrain-muffled voice is wrong for your game.
  • Mixer routing: VoiceMixer targets any descendant of the Voice mixer, the hook for a user voice-volume slider (don't repurpose Volume for that).

7. The two-peer verification checklist

Run once per project after wiring. Each row should be proven live:

#Check
1Host + joiner in session, both possessed, name tags visible both ways
2Hold PTT → other peer hears audio; speaking indicator lights; release → audio stops, indicator clears within the hold window. Both directions.
3No self-hearing while transmitting
4Falloff: walk away. Clearly audible inside the near core, fading, silent by max range
5Directionality: circle the speaker → voice pans; walk behind terrain → occlusion muffling
6Lip-sync: speaker's citizen mouth visibly moves while talking
7Solo sanity: single-player session has zero mic activity, zero errors

Check #2 is the keystone: it proves the create-before-NetworkSpawn premise (that a proxy's Msg_Voice RPC arrives and plays). Everything upstream of #2 is compile-proven only.

8. Pitfalls

IDPitfall
P1Component default Mode is AlwaysOn: a bare Voice relies entirely on the user's voip_mode preference. Always set PushToTalk explicitly.
P2Default Distance (381 m) and the default Falloff are wrong for proximity chat. Set both, with a tangent-0 plateau curve.
P3Playback config on a proxy comes from that peer's own OnStart, not replication: configure on every peer.
P4The "Voice" input action is usually but not always present in Input.config. Always verify; if missing, add it (editor restart required).
P5Dedicated server: Application.IsHeadless mutes playback, no server-side audio work needed.
P6Solo sessions: attach Voice only in the networked spawn path. This guarantees zero mic activity in single-player and makes Get<Voice>() == null the solo gate.
P7A sibling-component pattern works when the character class is frozen: create a new component alongside Voice on the same GameObject and configure the sibling in OnStart. No character edits needed.
P8If the SkinnedModelRenderer lives on a child GameObject, use Components.Get<SkinnedModelRenderer>(FindMode.EnabledInSelfAndDescendants) with a one-tick lazy retry (component start ordering isn't guaranteed).
Method guide, verified on engine 26.07.15a.
s&box moves fast; a stale step in a guide is a liability. Spot something off?

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord