Player Hosting Guide
Getting Started gets a call/broadcast running in a few lines. This guide is for building the real thing: what actually happens on the wire, and how to build a proper lobby/join UI on top of the events both components expose.
Overview#
Every peer opens one TURN allocation on your self-hosted coturn relay. The host (1-to-1) or broadcaster (host-broadcast) registers with your self-hosted rendezvous service and gets back a short join code; joiners/viewers resolve that code back to the host's session and connect through the relay. Nothing is Pivec Labs-hosted — you provide both pieces (see the Server Deployment Guide), and neither component works until Relay Settings actually points at a real server.
This replaces the two things a from-scratch WebRTC integration usually needs: a signaling server you write yourself, and manual SDP/ICE copy-paste for testing. The rendezvous service is that signaling server (already built, already deployed by the Server Setup Wizard); the join code is how two peers find each other's SDP without you wiring anything by hand.
What Host() / StartBroadcast() actually does#
POSTs to the rendezvous service, gets back a join code, firesJoinCodeReady(1-to-1) orBroadcastCodeReady(broadcast).- Captures local media (webcam/RenderTexture + mic, per your
videoSource/captureAudiosettings) — requesting Android runtime camera/mic permission first if needed.Sending video from Android requires
com.unity.webrtc3.0.0 or later. Earlier pre-release versions (confirmed on3.0.0-pre.8) have a hardware video encoder bug on Android: negotiation and connection both succeed normally, but no video ever actually reaches the remote peer from the Android side — indistinguishable from a real bug in your own integration unless you know to check the package version first. Receiving video on Android is unaffected either way. - Creates an
RTCPeerConnectionconfigured with your Relay Settings' TURN server (and STUN, unlessforceRelayis on), creates an SDP offer, and posts it to the rendezvous service under the join code. - Starts long-polling the rendezvous service for the answer and ICE candidates.
For NetCrafterLiveBroadcast specifically: media is captured once, regardless of viewer count, and one new RTCPeerConnection (with its own offer) is created per viewer as they join — ViewerJoined fires as each one is set up, not just once at broadcast start.
What Join() / JoinBroadcast(code) actually does#
- Resolves the join code against the rendezvous service. A typo, an expired code, or a host that never started broadcasting all surface as
ErrorOccurred, not a silent hang. - Creates its own
RTCPeerConnectionagainst the same TURN server.NetCrafterLiveBroadcastviewers addrecvonlytransceivers only — no camera/mic capture, nothing sent back to the host. - Starts long-polling for the host's offer, answers it, and exchanges ICE candidates the same way.
ConnectionStateChanged tracks the underlying RTCPeerConnection through New → Connecting → Connected (or Failed/Disconnected) — don't assume "joined the code successfully" means "connected"; wait for Connected before treating the call as live.
Force-Relay: what it does and when to use it#
forceRelay = true restricts ICE candidates to TURN-relayed ones only — no direct or STUN (server-reflexive) candidates. Two real uses: proving your relay path actually works end to end, and forcing relay for privacy (peers never learn each other's real IP).
If a force-relayed connection sits stuck at Connecting / never reaches Connected, check your Relay Settings' Shared Secret against the server's current turnserver.conf before suspecting anything else. A stale or wrong TURN credential produces exactly this symptom — negotiation succeeds, ICE gathering completes, nothing ever connects — indistinguishable from a real network problem from the client side. This has already cost real debugging time once; it's not hypothetical.
Building a join panel#
A minimal 1-to-1 join panel — join-code input, Connect button, status text, a UnityEvent for "call is live":
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;
using TMPro;
using Unity.WebRTC;
using PivecLabs.NetCrafterLive;
public class JoinPanel : MonoBehaviour
{
public NetCrafterLiveConnection connection;
public TMP_InputField joinCodeInput;
public Button connectButton;
public TMP_Text statusText;
public UnityEvent onCallConnected;
private void Start()
{
connectButton.onClick.AddListener(OnConnect);
connection.ConnectionStateChanged += OnStateChanged;
connection.ErrorOccurred += reason => statusText.text = "Error: " + reason;
}
private void OnDestroy()
{
connection.ConnectionStateChanged -= OnStateChanged;
}
private void OnConnect()
{
string code = joinCodeInput.text.Trim();
if (string.IsNullOrEmpty(code)) { statusText.text = "Enter a join code."; return; }
statusText.text = "Connecting to " + code + "...";
connection.Join(code);
}
private void OnStateChanged(RTCPeerConnectionState state)
{
statusText.text = "Call: " + state;
if (state == RTCPeerConnectionState.Connected) onCallConnected?.Invoke();
}
}
Wire onCallConnected in the Inspector to hide the lobby and show the call UI.
A host-broadcast viewer grid follows the same shape, but per-viewer on the host side:
public NetCrafterLiveBroadcast broadcast;
private readonly Dictionary<string, GameObject> _viewerTiles = new Dictionary<string, GameObject>();
private void Start()
{
broadcast.ViewerJoined += id => _viewerTiles[id] = SpawnPresenceTile(id);
broadcast.ViewerLeft += id => { if (_viewerTiles.TryGetValue(id, out var go)) Destroy(go); _viewerTiles.Remove(id); };
broadcast.ViewerStateChanged += (id, state) => UpdateTileColor(_viewerTiles[id], state);
}
Remember viewers are receive-only — there's nothing to preview per-viewer, just presence and connection state.
Testing locally#
Point Relay Settings at your real server (or a local/test coturn instance with a static long-term credential). Run two Unity Editor instances on the same machine (File → Open Project in New Window) and enable Run In Background in both (Edit → Project Settings → Player → Resolution and Presentation). Host on one, Join with the printed code on the other, and confirm RemoteVideoTextureAvailable actually fires with a real frame — reaching Connected alone doesn't prove video is flowing, only that negotiation succeeded.
Display modes: screen-space vs. world-space#
Both components expose a videoDisplayMode field (NetCrafterLiveVideoDisplayMode.ScreenSpace2D or .WorldSpace3D — see the API Reference) purely as a piece of state for your UI to react to. Neither component creates, positions, or hides any display object itself — you still own the RawImage/world-space quad and you still wire LocalVideoTextureAvailable/RemoteVideoTextureAvailable to it exactly as before. All the field buys you is one place to ask "which style is currently selected" and one event to find out when that changes.
When to use which:
ScreenSpace2D— the conventional choice for a standard call/broadcast UI: aRawImageon a Canvas, always facing the camera, always the same size on screen. Use this unless you have a specific reason not to.WorldSpace3D— for a video feed that lives in the scene: a video call rendered onto an object other players can walk around, a broadcast panel in a virtual space, VR/AR. The shipped demo scenes' world-space panels are also draggable and tilt slightly as you drag them near their edges (seeDemoWorldSpaceDraggable) — that behavior is demo-only polish, not something the component provides; build your own interaction on top of whatever object you're driving.
Reacting to a mode change from your own UI, the same pattern both demo scenes use — a Toggle that calls SetVideoDisplayMode(), and a handler subscribed to VideoDisplayModeChanged that does the actual showing/hiding (so it reacts correctly regardless of why the mode changed — your Toggle, a different script, or someone editing the field directly in the Inspector):
public NetCrafterLiveConnection connection; // or NetCrafterLiveBroadcast
public Toggle worldSpaceToggle;
public GameObject screenSpaceDisplay;
public GameObject worldSpaceDisplay;
private void Start()
{
ApplyDisplayMode(connection.videoDisplayMode); // sync to current state on startup
worldSpaceToggle.onValueChanged.AddListener(isWorldSpace =>
connection.SetVideoDisplayMode(isWorldSpace
? NetCrafterLiveVideoDisplayMode.WorldSpace3D
: NetCrafterLiveVideoDisplayMode.ScreenSpace2D));
connection.VideoDisplayModeChanged += ApplyDisplayMode;
}
private void OnDestroy()
{
connection.VideoDisplayModeChanged -= ApplyDisplayMode;
}
private void ApplyDisplayMode(NetCrafterLiveVideoDisplayMode mode)
{
bool worldSpace = mode == NetCrafterLiveVideoDisplayMode.WorldSpace3D;
worldSpaceDisplay.SetActive(worldSpace);
screenSpaceDisplay.SetActive(!worldSpace);
worldSpaceToggle.SetIsOnWithoutNotify(worldSpace); // keep the Toggle visually correct if the change came from elsewhere
}
Call SetVideoDisplayMode() rather than assigning videoDisplayMode directly from script — the setter is what fires VideoDisplayModeChanged. An Inspector edit to the field also fires the event (via OnValidate), so editor-time toggling for testing works the same way.
Viewer-only builds#
NetCrafterLiveBroadcast.allowHosting (default true) gates StartBroadcast() only — set it false to ship a build that can never become a broadcast source, e.g. a spectator/audience app, a "view-only" tier, or a kiosk build. JoinBroadcast() and every other viewer-side capability are completely unaffected; only hosting is cut off.
The check is enforced inside StartBroadcast() itself, not just at the UI layer — calling it while allowHosting is false raises ErrorOccurred and does nothing else, so a viewer-only build stays viewer-only even against a stray hotkey, a modified UI, or a scripting mistake, not just a hidden button.
The shipped Host-Broadcast demo follows the convention worth copying: hide (don't just disable) the Start Broadcast button up front, driven straight from the field, rather than relying on the runtime guard alone to communicate anything to the player:
private void Start()
{
startBroadcastButton.gameObject.SetActive(broadcast.allowHosting);
// ...
}
To build a dedicated spectator app: set allowHosting = false in the Inspector (or before Start() runs, from your own bootstrap script), and build a UI around JoinBroadcast()/LeaveBroadcast() only — a join-code field and a Connect button is enough; see §04 — Building a join panel above, which works unchanged for a viewer-only build since it never calls StartBroadcast() in the first place.