Add NetCrafter Live to Your Own Project
Prerequisites: com.unity.webrtc 3.0.0 or later installed (Package Manager); a relay server set up via the Server Setup Wizard (Tools → Pivec Labs → NetCrafter Live, or see the Server Deployment Guide); Relay Settings assigned to whatever component you add below, via the Assign Project's Relay Settings button its Inspector adds automatically.
Who this is for: you already have a game or app and want to drop video calling or host-broadcast into it directly — no demo scenes, no OneToOneCallDemoController/HostBroadcastDemoController reference scripts, just the two components' own public API against UI and meshes you already have. If you haven't seen the components working at all yet, Peer-to-Peer Video Call and Host-Broadcast run the shipped demo scenes first, which may be the faster way to get oriented before adapting this to your own project.
Part A — Screen-Space 1-to-1 Call
Two peers, a straight call, NetCrafterLiveConnection, shown on ordinary Canvas RawImages.
Add the component#
Add NetCrafterLiveConnection to any GameObject — a dedicated manager object is the usual choice, but it works anywhere. In its Inspector, click Assign Project's Relay Settings (added by the component's own custom editor — works on any GameObject with this component, in any scene). Leaving connectionSettings unassigned isn't silently ignored: Host()/Join() both refuse to run and raise a clear error through ErrorOccurred instead.
Leave videoDisplayMode at its default, ScreenSpace2D — that's the setting this component ships with, and Part A doesn't need to touch it at all. (It exists purely as a piece of state your own UI can read/react to; the component never creates or shows anything itself either way — see Part B for the other value, WorldSpace3D.)
A minimal UI#
Everything here is ordinary UnityEngine.UI/TextMeshPro — nothing provided by the package:
- Two
RawImages on a Canvas — one for your own outgoing video, one for the other peer's. - Three
Buttons — Host, Join, Leave. - A
TMP_InputFieldfor pasting in a join code, and a label to display the one you mint when hosting. - A status label (plain text is enough — this tutorial doesn't bother with the demo's color-by-state styling).
That's the whole UI surface. No audio UI needed — see A3.
Wire it up#
The whole integration is six lines: three button listeners, three event subscriptions.
hostButton.onClick.AddListener(() => connection.Host());
joinButton.onClick.AddListener(() => connection.Join(joinCodeInput.text));
leaveButton.onClick.AddListener(() => connection.Leave());
connection.LocalVideoTextureAvailable += tex => localPreview.texture = tex;
connection.RemoteVideoTextureAvailable += tex => remotePreview.texture = tex;
connection.JoinCodeReady += code => joinCodeLabel.text = "Join code: " + code;
connection.ConnectionStateChanged += state => statusLabel.text = "Status: " + state;
connection.ErrorOccurred += message => errorLabel.text = message;
Unsubscribe whatever you subscribed, in OnDestroy — connection doesn't do this for you, and a destroyed UI object still subscribed to a live event throws the moment that event next fires.
Android camera/mic permissions
Handled entirely for you. Host()/Join() both request Android's runtime camera + microphone permission internally (as one batched request, not two separate prompts) before attempting to capture anything — you never call anything permission-related yourself. The manifest-level CAMERA/RECORD_AUDIO declarations are added automatically by Unity's Player Settings, because the project uses the camera/microphone APIs at all (via this package) — nothing to add by hand there either. If permission is denied, Host()/Join() abort cleanly and raise it through ErrorOccurred rather than proceeding into a call with no media.
Remote audio
Plays automatically — if remoteAudioOutput is left unassigned (the default), a hidden AudioSource is created and used for you. Assign your own AudioSource there instead if you need spatial audio or specific mixer-group routing. Your own outgoing audio needs nothing from you either: leave captureAudio at its default (true) and the microphone is captured and sent automatically, or set it false for a video-only call.
Complete script#
Everything from A2/A3 as one file. Assign the six UI fields and connection in the Inspector.
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using Unity.WebRTC;
using PivecLabs.NetCrafterLive;
public class MyVideoCallUI : MonoBehaviour
{
public NetCrafterLiveConnection connection;
public RawImage localPreview;
public RawImage remotePreview;
public TMP_InputField joinCodeInput;
public Button hostButton;
public Button joinButton;
public Button leaveButton;
public TMP_Text statusLabel;
public TMP_Text joinCodeLabel;
public TMP_Text errorLabel;
private void Start()
{
hostButton.onClick.AddListener(() => connection.Host());
joinButton.onClick.AddListener(() => connection.Join(joinCodeInput.text));
leaveButton.onClick.AddListener(OnLeave);
connection.LocalVideoTextureAvailable += tex => localPreview.texture = tex;
connection.RemoteVideoTextureAvailable += tex => remotePreview.texture = tex;
connection.JoinCodeReady += OnJoinCodeReady;
connection.ConnectionStateChanged += OnConnectionStateChanged;
connection.ErrorOccurred += OnError;
statusLabel.text = "Status: New";
joinCodeLabel.text = "";
errorLabel.text = "";
}
private void OnDestroy()
{
connection.JoinCodeReady -= OnJoinCodeReady;
connection.ConnectionStateChanged -= OnConnectionStateChanged;
connection.ErrorOccurred -= OnError;
}
private void OnLeave()
{
connection.Leave();
statusLabel.text = "Status: New";
joinCodeLabel.text = "";
localPreview.texture = null;
remotePreview.texture = null;
}
private void OnJoinCodeReady(string code) => joinCodeLabel.text = "Join code: " + code;
private void OnConnectionStateChanged(RTCPeerConnectionState state) => statusLabel.text = "Status: " + state;
private void OnError(string message) => errorLabel.text = message;
}
Compiled against this package's actual current API to verify it builds — see the report for this tutorial for how.
Common problems#
- Nothing shows at all. Almost always
connectionSettingsunassigned — check the Console forconnectionSettings is not assigned..., or the error label if you wiredErrorOccurredto one. Second most likely: theRawImages were never actually wired toLocalVideoTextureAvailable/RemoteVideoTextureAvailable. - No audio. Check
captureAudioistrueon the sending side and that a real microphone device exists on that machine (Microphone.devices.Length == 0silently proceeds video-only — this is deliberate, not a bug, so a desktop with no mic doesn't block the whole call). On the receiving side, confirm you haven't assigned aremoteAudioOutputthat's muted or routed somewhere you're not listening. - Relay Settings not assigned. See A1 — click Assign Project's Relay Settings, or confirm the Relay Settings tab (Tools → Pivec Labs → NetCrafter Live) actually has a real Relay Host and Rendezvous URL filled in, not blank fields.
- Stuck at
Connecting, never reachesConnected(ICE timeout). Most often a stale or wrong TURN credential — see the Player Hosting Guide's Force-Relay section for the exact symptom and how to confirm it, rather than duplicating that explanation here.
Part B — World-Space Host Broadcast
One host, any number of receive-only viewers, NetCrafterLiveBroadcast, shown on a mesh in your scene instead of screen-space UI.
Add the component#
Add NetCrafterLiveBroadcast to a GameObject the same way as Part A, and click Assign Project's Relay Settings — the same button, same custom editor, on this component too. Set videoDisplayMode to WorldSpace3D (default is ScreenSpace2D, same as the other component — it's purely state your UI/display code reads, never anything the component acts on itself).
A single NetCrafterLiveBroadcast instance can be either the host or a viewer — which one depends only on whether you call StartBroadcast() or JoinBroadcast(code), not a separate Inspector/compile-time choice. A real game usually can't know in advance which player will host, so don't build two separate prefabs for this.
Show it on a mesh#
LocalVideoTextureAvailable/RemoteVideoTextureAvailable hand you a plain Texture regardless of display mode — the component never creates, positions, or touches any display object itself. Putting it on a mesh (a screen/TV model, a plane, anything with a Renderer) is just:
var material = screenRenderer.material; // instance-unique, NOT .sharedMaterial --
// see the warning below
broadcast.RemoteVideoTextureAvailable += tex => material.mainTexture = tex;
Use Renderer.material, never .sharedMaterial, if more than one mesh in your scene might show a NetCrafter Live texture (e.g. a host preview AND a viewer screen). .sharedMaterial is the same Material asset across every renderer using it — assigning a texture through it on one screen overwrites the texture on every other screen sharing that material too. .material gives each Renderer its own instance the first time it's accessed, exactly to avoid this.
Material / shader setup
The mesh needs an unlit (or otherwise texture-displaying) material. On URP, Shader.Find("Universal Render Pipeline/Unlit"); on the Built-in render pipeline, Shader.Find("Unlit/Texture"). Using URP's shader name on a Built-in-pipeline project (or vice versa) renders as a flat pink "unsupported by current render pipeline" error, not a compile failure — easy to mistake for a real bug the first time you hit it.
The V-flip (mesh vs. RawImage)
A mesh Renderer's material and UnityEngine.UI's RawImage use opposite V-axis conventions by default — the exact same texture that displays right-side-up in a RawImage (Part A) can show upside-down on a mesh, independent of which primitive carries the material. Confirmed live against the shipped demo's own world-space panels, the fix is the material's own texture transform, not the mesh's UVs or the source texture:
material.mainTextureScale = new Vector2(-1f, -1f);
material.mainTextureOffset = new Vector2(1f, 1f);
This is the fix the demo's specific cube-and-camera combination needed (the demo's camera looks at the cube's -Z face, which mirrors a primitive's conventional UV unwrap — see DemoWorldSpaceVideoDisplay.cs's header comment for the full reasoning if you're curious). Your own mesh and camera setup may need a different fix, or none at all — treat the snippet above as a known-working starting point to verify visually, not a universal constant every setup needs applied blindly. If you'd rather reference the shipped version directly instead of copying it, it's Examples/Shared/DemoWorldSpaceVideoDisplay.cs — marked example-only (not a guaranteed-stable public API), so copying its ~15 lines into your own script is the safer long-term choice.
Optional: broadcasting a RenderTexture instead of the webcam
Set videoSource = NetCrafterLiveVideoSource.RenderTexture and assign customRenderTexture to your own game camera's render target — e.g. to broadcast gameplay, a replay camera, or any other in-game view instead of a physical webcam. The shipped demo scenes already do exactly this for their "Use RenderTexture source" toggle (see DemoRenderTextureSource.cs) if you want a working reference.
Viewer-only builds & runtime mode switching#
Viewer-only (spectator) builds
Set allowHosting = false to ship a build that can only ever JoinBroadcast() — e.g. a spectator/audience app. StartBroadcast() checks this itself and refuses to run (raising ErrorOccurred) if it's false, so this is a real guarantee, not just a convention for your own UI to also hide the Start button behind:
hostButton.gameObject.SetActive(broadcast.allowHosting); // hide it too, don't rely on the guard alone
Switching display mode at runtime
Call SetVideoDisplayMode rather than assigning videoDisplayMode directly — the setter is what raises VideoDisplayModeChanged so any UI/display logic reacting to it stays in sync:
broadcast.SetVideoDisplayMode(NetCrafterLiveVideoDisplayMode.ScreenSpace2D);
broadcast.VideoDisplayModeChanged += mode => /* show/hide your screen-space vs. world-space display objects */;
Editing videoDisplayMode directly in the Inspector also raises the event (via OnValidate) — only a plain script assignment (broadcast.videoDisplayMode = x) silently skips it. See the Player Hosting Guide's display-modes section for the full pattern both demo scenes use.
Complete script#
Host control, viewer control, and mesh display together — one component, usable as either role depending on which public method you call. Assign broadcast and screenRenderer in the Inspector.
using UnityEngine;
using Unity.WebRTC;
using PivecLabs.NetCrafterLive;
public class MyBroadcastScreen : MonoBehaviour
{
public NetCrafterLiveBroadcast broadcast;
[Tooltip("The mesh (e.g. a screen/TV model) that shows the video.")]
public Renderer screenRenderer;
[Tooltip("true on the host's own preview, false on a viewer's screen.")]
public bool showLocal;
private Material _material;
private void Awake()
{
broadcast.SetVideoDisplayMode(NetCrafterLiveVideoDisplayMode.WorldSpace3D);
_material = screenRenderer.material; // instance-unique -- see Part B2
// Verify against your own mesh/camera -- this fixes the flip the
// shipped demo's cube/camera setup needed; yours may differ.
_material.mainTextureScale = new Vector2(-1f, -1f);
_material.mainTextureOffset = new Vector2(1f, 1f);
}
private void OnEnable()
{
if (showLocal) broadcast.LocalVideoTextureAvailable += SetTexture;
else broadcast.RemoteVideoTextureAvailable += SetTexture;
broadcast.ViewerJoined += OnViewerJoined;
broadcast.ViewerLeft += OnViewerLeft;
broadcast.ErrorOccurred += OnError;
}
private void OnDisable()
{
if (showLocal) broadcast.LocalVideoTextureAvailable -= SetTexture;
else broadcast.RemoteVideoTextureAvailable -= SetTexture;
broadcast.ViewerJoined -= OnViewerJoined;
broadcast.ViewerLeft -= OnViewerLeft;
broadcast.ErrorOccurred -= OnError;
}
// Call these from your own UI buttons / game logic.
public void HostNow()
{
if (!broadcast.allowHosting) return; // see Part B3 -- StartBroadcast() also refuses on its own
broadcast.StartBroadcast();
}
public void JoinNow(string code) => broadcast.JoinBroadcast(code);
public void StopHosting() => broadcast.StopBroadcast();
public void LeaveViewing() => broadcast.LeaveBroadcast();
private void SetTexture(Texture tex) => _material.mainTexture = tex;
private void OnViewerJoined(string viewerId) => Debug.Log("Viewer joined: " + viewerId);
private void OnViewerLeft(string viewerId) => Debug.Log("Viewer left: " + viewerId);
private void OnError(string message) => Debug.LogError(message);
}
Compiled against this package's actual current API to verify it builds — see the report for this tutorial for how.
Common problems#
- Video shows upside-down or mirrored. See B2 — this is a mesh/material UV convention difference from
RawImage, not a bug in the texture you're receiving. Try themainTextureScale/mainTextureOffsetfix from B2 and adjust for your own camera-to-mesh facing if it's still wrong. - Nothing shows on the mesh at all. Confirm you used
Renderer.material(not.sharedMaterial— see B2's warning), and thatLocalVideoTextureAvailable/RemoteVideoTextureAvailableare actually wired to the renderer you're looking at, not a different one. - No audio. Same causes as Part A — confirm
captureAudioon the host and a real microphone device on that machine; confirmremoteAudioOutput(if assigned) isn't muted/misrouted on the viewer side. - Stuck at
Connecting(ICE timeout). Same as Part A — see the Player Hosting Guide's Force-Relay section for the stale-TURN-credential symptom this most often turns out to be. - A viewer-only build still shows a working Start Broadcast button.
allowHosting = falseonly gatesStartBroadcast()itself — your own UI still needs to hide/disable the button separately (see B3). Clicking it withallowHostingfalse correctly does nothing except raiseErrorOccurred, but a visible dead button is still worth hiding.
Where to go from here#
- API Reference — the complete field/event/method list for both components, including everything referenced on this page.
- Player Hosting Guide — what actually happens on the wire when you call
Host()/Join()/StartBroadcast()/JoinBroadcast(), Force-Relay, and the display-mode pattern in full. - Peer-to-Peer Video Call / Host-Broadcast — the shipped demo scenes this tutorial deliberately didn't use, if you want to see a complete reference project running first.
- Manual — Relay Settings and the Server Setup Wizard in full.