NetCrafter Relay Tutorial: Chat Quick Start
The fastest path to proving the full Editor-to-runtime pipeline works: schema, codegen, scene, and a real host + joiner chat session.
Time: 45–60 minutes
Prerequisites: NetCrafter Relay installed; your own self-hosted coturn + rendezvous instance up and running (see the Server Deployment Guide); Allow downloads over HTTP set to Always allowed in Player Settings (Edit → Project Settings → Player → Other Settings → Configuration — a fresh project defaults to blocking this, which fails the very first connection attempt with InvalidOperationException: Insecure connection not allowed; see Getting Started); Unity's Input System package active (Edit → Project Settings → Player → Active Input Handling set to Input System Package or Both); TextMeshPro essentials imported (Window → TextMeshPro → Import TMP Essentials, click Import when prompted — a one-time step per project).
What you'll build: A minimal text chat — named login, host a session or join one by code, send and receive messages.
This is the quick-start tutorial — the fastest path to proving the whole pipeline works end to end in your own project: define a schema, generate code, wire up a scene, run a real host + joiner session. It is deliberately not the flagship example — see the 2-4 Player Co-op tutorial for real-time position sync and host-authoritative gameplay (item pickups, scoring) if you're looking for full gameplay patterns rather than a first working example. This tutorial assumes no prior Unity UI experience — every panel, field, and button is built from scratch, step by step.
00 — Architecture: host-relay-only#
Every peer — host and joiners alike — opens one TURN allocation on your coturn relay and exchanges messages through it. The host does all the fan-out: when a joiner sends a chat message, it goes to the host first, and the host rebroadcasts it to everyone (including back to other joiners) via BroadcastToAll.
Joiners cannot message each other directly. There is no peer-to-peer path in this architecture — everything routes through whichever player is hosting. Design your game logic around the host as the single point every message passes through, not around direct joiner-to-joiner communication.
This is why the chat demo below has a SendChat message (joiner → host) and a separate ChatBroadcast event (host → everyone) instead of a single two-way message — that split is the host-relay model.
Part 1 — Create the schema#
1.1 Open the NetCrafter Relay window
Tools → Pivec Labs → NetCrafter Relay.
1.2 Create a new schema
Click + Create Definition, enter ChatDemo, click Create. This creates Assets/PivecLabs/NetCrafterRelay/Definitions/ChatDemo/ with all the standard subfolders (Messages, DataDefinitions, HostActions, SyncRules, Generated) and a fresh RelaySettings.asset — with no working defaults, by design (see Part 4).
Part 2 — Define the messages#
Open the Gameplay Messages tab. Click Create Gameplay Message three times and fill each in as follows.
2.1 Login — client tells the host its display name
| Field | Value |
|---|---|
| Message Name | Login |
| Direction | ClientToHost |
| Transport | Reliable |
| Host Handler Name | HandleLogin |
Add one field: displayName (type string).
2.2 SendChat — client sends a chat message
| Field | Value |
|---|---|
| Message Name | SendChat |
| Direction | ClientToHost |
| Transport | Reliable |
| Host Handler Name | HandleSendChat |
Add one field: text (type string).
2.3 ChatBroadcast — host fans a chat message out to everyone
| Field | Value |
|---|---|
| Message Name | ChatBroadcast |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank — optional for host-to-client events) |
Add two fields: senderName (type string), text (type string).
Notice ChatBroadcast has a senderName field that SendChat doesn't. That's intentional — the host looks up the sender's name (registered at login) and fills it in itself. The generator handles this cleanly: any response-event field with no matching trigger-message field gets a // TODO: ... set it explicitly comment in the generated code instead of guessing. You'll set it explicitly in Part 3.
Part 3 — Define the Host Actions#
Open the Host Actions tab. Create two.
3.1 HandleLogin
| Field | Value |
|---|---|
| Handler Name | HandleLogin |
| Trigger Message | Login |
| Response Event | (none) |
| Logic Type | Custom |
Custom Host Code:
ChatPlayerRegistry.Names[senderId] = trigger.displayName;
if (connection.debugLogging) Debug.Log($"[ChatDemo] Player {senderId} logged in as '{trigger.displayName}'");
3.2 HandleSendChat
| Field | Value |
|---|---|
| Handler Name | HandleSendChat |
| Trigger Message | SendChat |
| Response Event | ChatBroadcast |
| Logic Type | Custom |
Custom Host Code:
string senderName = ChatPlayerRegistry.Names.TryGetValue(senderId, out var pname) ? pname : ("Player" + senderId);
var fixedEvent = new ChatBroadcastEvent { senderName = senderName, text = trigger.text };
connection.BroadcastToAll("ChatBroadcast", fixedEvent.Serialize(), true);
// BroadcastToAll reaches every registered joiner over the relay, but never loops back to
// this machine -- dispatch locally too, so the host's own Scene Event Router binding (and
// therefore its own chat log) sees other players' messages, not just its own.
PivecLabs.NetCrafterRelay.NetCrafterEventDispatcher.Instance?.Dispatch("ChatBroadcast", fixedEvent);
Why Custom instead of the simpler BroadcastAll logic type? BroadcastAll fires automatically from the auto-generated response object, but that object is built (and serialized) before your custom code runs — so it can't retroactively fix up a field the auto-generated assignment couldn't fill in. Custom gives you the full method body, so you can build your own event object with the looked-up name and broadcast that instead. This is exactly what the Custom logic type is for.
Why the fully-qualified PivecLabs.NetCrafterRelay.NetCrafterEventDispatcher instead of a using directive? Custom Host Code is raw text dropped straight into a generated method body — there's no using-directive placeholder for a namespaced helper, so any base-package type has to be fully qualified here (a plain, global-namespace helper like ChatPlayerRegistry doesn't have this problem).
3.3 Add the player-name registry
Both handlers above reference ChatPlayerRegistry — a tiny static helper you add yourself, since it needs to persist across message handlers for the lifetime of a session. Create Assets/PivecLabs/Examples/ChatDemo/Scripts/ChatPlayerRegistry.cs:
using System.Collections.Generic;
public static class ChatPlayerRegistry
{
public static readonly Dictionary<int, string> Names = new();
}
Keep this file outside Assets/PivecLabs/NetCrafterRelay/ — scripts inside that folder compile into a separate assembly that can't see the generated client/host code. Assets/PivecLabs/Examples/ChatDemo/Scripts/ (no .asmdef in this folder) compiles into Assembly-CSharp alongside the generated code, which is what makes this work.
This is a plain static class, not a MonoBehaviour — there is no GameObject to create or attach it to, and nothing to drag anywhere in the Inspector. Having the file anywhere in your project is enough; the Host Action custom code above already references ChatPlayerRegistry.Names directly. If you're looking for "which GameObject does this go on" — the answer for this particular script is: none.
Part 4 — Configure Relay Settings#
Open Settings → Relay Settings. Every field below is blank by default and shows a warning until filled in — that's intentional, not a bug: NetCrafter Relay ships fully self-hosted, with no Pivec Labs-run relay or rendezvous service to fall back to.
| Field | Value |
|---|---|
| Relay Host | Your own coturn server's public IP or hostname — no scheme |
| Relay Port | 3478 |
| Rendezvous URL | Your own rendezvous service's URL — requires the http:// prefix, e.g. http://<your-server-ip>:8787 |
| Credential Mode | StaticSecret |
| TURN Username | Must match user=<name>:<secret> in your turnserver.conf |
| Shared Secret | The same secret from that same line |
Relay Host and Rendezvous URL sit right next to each other but expect different formats — Relay Host is a bare host with no scheme (TURN doesn't use one), while Rendezvous URL is a plain HTTP endpoint and needs http:// (or https://) in front of it. Leaving the scheme off is auto-corrected at runtime, but it used to fail with a generic, confusing "Cannot connect to destination host" — enter it with the scheme, as shown above.
If you haven't stood up coturn and the rendezvous service yet, do that first — see the Server Deployment Guide, which covers both on the same box.
Click Save Settings.
Part 5 — Generate#
Open Build Code, click Validate Definition (optional sanity check), then click Generate All Files.
This writes to Definitions/ChatDemo/Generated/:
Client/LoginRequest.cs,Client/SendChatRequest.cs,Client/ChatBroadcastEvent.cs,Client/RelayConnectionManager.csHost/HostActionRouter.csConfig/RelayConfig.json(or similar, perNC_GenerateConfig)
Let Unity finish recompiling, then check the Console — zero errors expected. If you see CS0103: The name 'ChatPlayerRegistry' does not exist in the current context, double-check the file from Part 3.3 exists and has no namespace wrapped around the class.
What Generate does not produce: everything in Part 6 below — the scene, the UI, and ChatDemoController.cs. Those are things you (or this tutorial, on your behalf) build once, by hand, same as any other Unity gameplay script. Don't expect to find a ChatDemoController.cs appear anywhere under Generated/ — it never will. NetCrafter Relay generates your networking code; your UI and game logic are still yours to write, same as in any Unity project.
Part 6 — Understand the event system#
Before building the scene, it's worth knowing about two components you'll be adding, since neither is generated and neither is obvious from its name alone.
NetCrafterEventDispatcher
This ships with the base NetCrafter Relay package (Scripts/NetCrafterEventDispatcher.cs) — it is not generated per-schema, and it's the same one class regardless of what your schema looks like. It's a small runtime pub/sub bus: every incoming message, both the two built-in connection-lifecycle notifications (RelayHosting, ConnectionLost) and every HostToClient event your schema defines, gets funneled through it via Dispatch(cmd, payload). You need exactly one NetCrafterEventDispatcher component in your scene, on any GameObject, for any of this to work — without it, nothing fails loudly, but no event handler anywhere will ever fire. You'll add this GameObject in Part 6.1.
Code can subscribe to it directly (NetCrafterEventDispatcher.Instance.Subscribe("SomeEvent", callback)) — this tutorial does that for the two built-in lifecycle notifications, since those aren't schema events and have no other wiring path. But for schema-defined events like ChatBroadcast, there's a friendlier option:
NetCrafterSceneEventRouter
This is a higher-level, no-code wrapper around the dispatcher, built specifically for schema events. Open Settings → Scene Events and click Add Router to Scene — it adds a NetCrafterSceneEventRouter GameObject and auto-lists every HostToClient event in your schema (here, just ChatBroadcast), each with its own UnityEvent called On Received that you wire up in the Inspector exactly like a Button's On Click () — no Subscribe() call to write yourself. Internally, the router subscribes to NetCrafterEventDispatcher on your behalf, one binding per schema event, and invokes your wired method when that event arrives. You'll use this in Part 6.4 for ChatBroadcast.
Put simply: the dispatcher is the low-level bus every event goes through; the router is an Inspector-friendly shortcut for schema events specifically. You still need both GameObjects in the scene — the router doesn't replace the dispatcher, it sits on top of it.
Part 7 — Build the scene#
Keep this simple — the point of this tutorial is proving the pipeline, not polishing UI. Every step below assumes an empty scene with nothing in it yet.
7.1 Create the Canvas
- In the Hierarchy, right-click → UI → Canvas. This also creates an EventSystem GameObject automatically if one doesn't already exist.
- On the Canvas component, confirm Render Mode is Screen Space - Overlay.
7.2 Create the Login panel
- Right-click Canvas → UI → Panel — name it
LoginPanel. Leave it stretched to fill the parent (the default anchor). - Right-click
LoginPanel→ UI → Text - TextMeshPro — name itTitle. Set its text toChat Room Login, center-align it, and move it toward the top of the panel. - Right-click
LoginPanel→ UI → Input Field - TextMeshPro — name itDisplayNameInput. Set its Placeholder child's text toEnter your name.... Position it belowTitle. - Right-click
LoginPanel→ UI → Button - TextMeshPro — name itContinueButton. Set its label's text toContinue. Position it belowDisplayNameInput.
7.3 Create the Lobby panel
- Right-click Canvas → UI → Panel — name it
LobbyPanel. - Right-click
LobbyPanel→ UI → Button - TextMeshPro — name itHostButton. Label:Host Game. Position it near the top. - Right-click
LobbyPanel→ UI → Input Field - TextMeshPro — name itJoinCodeInput. Placeholder text:Join code.... Position it belowHostButton. - Right-click
LobbyPanel→ UI → Button - TextMeshPro — name itJoinButton. Label:Join Game. Position it belowJoinCodeInput. - Right-click
LobbyPanel→ UI → Text - TextMeshPro — name itJoinCodeDisplay. Leave the text blank for now — the script fills it in once you're hosting. Position it near the bottom. - Right-click
LobbyPanel→ UI → Text - TextMeshPro — name itStatusText. Leave the text blank. Position it just belowJoinCodeDisplay. This is where connection errors ("Couldn't reach the relay server — check your connection") and transient retry messages ("Retrying connection… (attempt 2 of 3)") show up — kept as its own field, separate fromJoinCodeDisplay, so a success message and an error/retry message never have to share one field. - Select
LobbyPanelin the Hierarchy and uncheck the checkbox next to its name in the Inspector to disable it — you start on the Login panel, not this one.
7.4 Create the Chat panel
- Right-click Canvas → UI → Panel — name it
ChatPanel. - Right-click
ChatPanel→ UI → Scroll View — name itChatScrollView. Uncheck Horizontal scrolling (keep Vertical checked). Resize it to fill roughly the top three-quarters ofChatPanel. - Expand
ChatScrollViewin the Hierarchy: Viewport → Content. - Right-click Content → UI → Text - TextMeshPro — name it
ChatLog. Set its anchors to stretch horizontally (left = 0, right = 1) with left/right offsets of 0. Set its alignment to Top Left. - Select Content itself (not
ChatLog) and add two components: a Vertical Layout Group (Padding all 0, Spacing 0, Child Alignment Upper Left, Child Controls Size checked for both Width and Height, Child Force Expand checked for Width only) and a Content Size Fitter (Horizontal Fit Unconstrained, Vertical Fit Preferred Size).
Put the Content Size Fitter on Content, not on ChatLog. This is the opposite of the more common tutorial pattern of fitting a single Text object to its own text, and it's an easy, natural-feeling mistake to make — but a ScrollRect computes how far it can scroll from its Content object's size, not from any of Content's children. A Content Size Fitter on ChatLog alone makes ChatLog visually grow to fit its text, but Content never grows to match, so the ScrollRect keeps thinking there's nothing new to scroll to — confirmed live, this is exactly what "scrolling doesn't work well" turned out to be. The Vertical Layout Group on Content is what lets its Content Size Fitter compute a real "preferred size" from ChatLog in the first place (a bare Content with no layout group and just a Content Size Fitter has nothing to measure).
- Right-click
ChatPanel→ UI → Input Field - TextMeshPro — name itMessageInput. Placeholder text:Type a message.... Position it at the bottom-left ofChatPanel. - Right-click
ChatPanel→ UI → Button - TextMeshPro — name itSendButton. Label:Send. Position it to the right ofMessageInput. - Select
ChatPanelin the Hierarchy and uncheck it to disable it, same as you did forLobbyPanel.
7.5 Add the relay and event GameObjects
- Create Empty (right-click in the Hierarchy) — name it
RelayConnectionManager. In Part 7.7 you'll attach the generatedRelayConnectionManagercomponent to it. - Create Empty — name it
HostActionRouter. You'll attach the generatedHostActionRoutercomponent here. - Create Empty — name it
NetCrafterEventDispatcher. Attach the NetCrafterEventDispatcher component to it (Add Component in the Inspector, search for it by name) — see Part 6 for what this does. - Select the
RelayConnectionManagerGameObject and uncheck it to disable it.
This checkbox matters more than it looks like it should. RelayConnectionManager's Start() calls Connect() unconditionally, and Connect() defaults to hosting a brand-new session whenever no join code is pending. Leave this GameObject checked active by mistake, and the app hosts a real session the instant the scene loads — zero clicks, zero input, before your Login screen has even been looked at, let alone used. This is a genuinely easy mistake to make: the GameObject looks identical either way in the Hierarchy, and everything else about the scene (hierarchy, references, script attachments) can be perfectly correct while this one checkbox is wrong. ChatDemoController.Awake() now forces this GameObject inactive defensively every time the scene loads, specifically so a reader leaving it checked doesn't silently break the demo — but get the checkbox right here too, since that's the behavior the code is defending against, not a substitute for it.
7.6 Add the Scene Event Router
- Go to Settings → Scene Events in the NetCrafter Relay window.
- Click Add Router to Scene. This creates a
SceneEventRouterGameObject with aNetCrafterSceneEventRoutercomponent, and listsChatBroadcast(the onlyHostToClientevent in this schema) with an empty On Received slot. Leave it empty for now — you'll wire it in Part 7.9, afterChatDemoControllerexists to wire it to.
7.7 Attach the generated RelayConnectionManager and HostActionRouter
- Select the
RelayConnectionManagerGameObject. Add Component → search for RelayConnectionManager (the generated component from Part 5) → add it. - Select the
HostActionRouterGameObject. Add Component → search for HostActionRouter → add it.
7.8 Attach the ChatDemoController script
ChatDemoController.cs already ships with the package — you don't write it from scratch. It's at:
Assets/PivecLabs/Examples/ChatDemo/Scripts/ChatDemoController.cs
- Create Empty in the Hierarchy — name it
ChatDemoController. - Drag
ChatDemoController.csonto it (or Add Component → search for Chat Demo Controller). - With
ChatDemoControllerselected, wire up its Inspector fields:
| Field | Drag in |
|---|---|
| Relay Connection Manager Object | the RelayConnectionManager GameObject |
| Login Panel | LoginPanel |
| Display Name Input | DisplayNameInput |
| Lobby Panel | LobbyPanel |
| Join Code Input | JoinCodeInput |
| Join Code Display | JoinCodeDisplay |
| Status Text | StatusText — optional; leave unassigned and this feature is silently skipped |
| Chat Panel | ChatPanel |
| Message Input | MessageInput |
| Chat Log | ChatLog (inside ChatScrollView/Viewport/Content) |
| Chat Scroll Rect | ChatScrollView (its ScrollRect component) — optional, but without it new messages won't auto-scroll into view |
7.9 Wire the buttons and the ChatBroadcast event
- Select
ContinueButton. In the Inspector, find Button → On Click (), click +, drag theChatDemoControllerGameObject into the object slot, and pick ChatDemoController → OnLoginClicked () from the function dropdown. - Select
HostButton. Same steps, picking OnHostClicked (). - Select
JoinButton. Same steps, picking OnJoinClicked (). - Select
SendButton. Same steps, picking OnSendClicked (). - Select the
SceneEventRouterGameObject you created in Part 7.6. FindChatBroadcast's On Received UnityEvent, click +, dragChatDemoControllerinto the object slot, and pick ChatDemoController → OnChatBroadcastReceived ().
That's every wiring step — no code left to write. Here's what ChatDemoController.cs actually does, for reference:
using System.Collections;
using UnityEngine;
using UnityEngine.UI;
using TMPro;
using PivecLabs.Generated;
using PivecLabs.NetCrafterRelay;
public class ChatDemoController : MonoBehaviour
{
public GameObject relayConnectionManagerObject;
public GameObject loginPanel;
public TMP_InputField displayNameInput;
public GameObject lobbyPanel;
public TMP_InputField joinCodeInput;
public TMP_Text joinCodeDisplay;
public TMP_Text statusText; // optional -- errors/retry status on the Lobby panel
public GameObject chatPanel;
public TMP_InputField messageInput;
public TMP_Text chatLog;
public ScrollRect chatScrollRect;
private RelayConnectionManager relay;
private string displayName = "Player";
private void Awake()
{
// ChatPlayerRegistry.Names is a static dictionary -- it survives across repeated Unity
// Editor Play-mode sessions when Domain Reload is disabled (Project Settings -> Editor
// -> Enter Play Mode Settings), a common performance setting. Without clearing it here,
// a second Play session can inherit a stale display name from a previous one under the
// same user id (the host is always id 0). Standalone builds always start a fresh
// process, so this only matters for repeated in-Editor testing.
ChatPlayerRegistry.Names.Clear();
relay = relayConnectionManagerObject.GetComponent<RelayConnectionManager>();
// Confirmed live: RelayConnectionManager.Start() unconditionally calls Connect(), which
// defaults to hosting a new session when no join code is pending. If Part 7.5's checkbox
// is left active by mistake, the app auto-hosts the instant the scene loads -- zero
// clicks, entirely bypassing Login/Lobby. Force it inactive here defensively, the same
// pattern already used below for the panel states, so correct behavior doesn't depend on
// a reader noticing and manually unchecking a box.
relayConnectionManagerObject.SetActive(false);
ShowLogin();
}
private void Start()
{
// Start() runs after every Awake() in the scene, so the dispatcher is guaranteed to
// exist here even though Awake ordering between separate GameObjects isn't guaranteed.
NetCrafterEventDispatcher.Instance.Subscribe("RelayHosting", OnRelayHosting);
NetCrafterEventDispatcher.Instance.Subscribe("ConnectionLost", OnConnectionLost);
NetCrafterEventDispatcher.Instance.Subscribe("RelayStatus", OnRelayStatus);
}
private void ShowLogin() { loginPanel.SetActive(true); lobbyPanel.SetActive(false); chatPanel.SetActive(false); }
private void ShowLobby() { loginPanel.SetActive(false); lobbyPanel.SetActive(true); chatPanel.SetActive(false); }
private void ShowChat() { loginPanel.SetActive(false); lobbyPanel.SetActive(false); chatPanel.SetActive(true); }
public void OnLoginClicked()
{
displayName = string.IsNullOrEmpty(displayNameInput.text) ? "Player" : displayNameInput.text;
ShowLobby();
}
public void OnHostClicked()
{
if (statusText != null) statusText.text = ""; // clear any error/retry text from a previous attempt
relayConnectionManagerObject.SetActive(true);
}
public void OnJoinClicked()
{
string code = joinCodeInput.text.Trim();
if (string.IsNullOrEmpty(code)) return;
if (statusText != null) statusText.text = "";
relay.UpdateHost(code);
relayConnectionManagerObject.SetActive(true);
StartCoroutine(SendLoginWhenConnected());
}
private void OnRelayHosting(object joinCodeObj)
{
// Just update the join code text and stay on the Lobby panel here -- it's already
// showing (OnHostClicked doesn't change panels). SendLoginWhenConnected() below is the
// only place that transitions to Chat. An earlier version of this called ShowChat()
// right here too, which deactivated the Lobby panel in the very same frame the join
// code was set on it -- confirmed live, the host never actually saw JoinCodeDisplay,
// only the join code in the Debug log.
joinCodeDisplay.text = "Join code: " + (string)joinCodeObj;
StartCoroutine(SendLoginWhenConnected());
}
private void OnConnectionLost(object reason)
{
// Additive -- AppendLog (and its own Debug.Log) is unchanged. This is for the Lobby
// panel, which is where a failed host/join attempt actually leaves the player -- Chat's
// own log is never shown yet at that point.
AppendLog("[system] Connection lost: " + reason);
if (statusText != null) statusText.text = NetCrafterConnectionStatus.ToPlayerMessage((string)reason);
}
// "RelayStatus" carries transient, already-friendly text (e.g. a retry-in-progress message)
// rather than a raw reason string, so it's shown as-is instead of going through the mapper.
private void OnRelayStatus(object message)
{
if (statusText != null) statusText.text = (string)message;
}
private const float HostLobbyDisplaySeconds = 4f;
// Confirmed live: the rendezvous lookup alone can retry up to 3 times at 15s each on a slow
// or flaky connection (RelayConnectionManager's RendezvousRequestTimeoutSeconds/
// maxJoinAttempts) before TURN permissions and the hello/ack handshake even start -- a
// real-network join can take well over 10 seconds without anything actually being wrong.
private const float ConnectTimeoutSeconds = 60f;
private IEnumerator SendLoginWhenConnected()
{
float deadline = Time.realtimeSinceStartup + ConnectTimeoutSeconds;
while (!relay.IsConnected && Time.realtimeSinceStartup < deadline) yield return null;
if (!relay.IsConnected) { AppendLog($"[system] Failed to connect within {ConnectTimeoutSeconds}s."); yield break; }
// RelayConnectionManager.Send() only has an implicit destination for joiners -- on
// the host it's a documented no-op with a warning, since the host has no single "the"
// peer to send to (see API Reference). The host registers its own name directly
// instead of routing a Login message to itself.
if (relay.IsHost)
{
ChatPlayerRegistry.Names[relay.LocalUserId] = displayName;
// Confirmed live: RelayConnectionManager marks the host "connected" the instant its
// own rendezvous registration succeeds -- the same moment the join code becomes
// available -- so the wait-for-connected loop above never actually waits for a
// host. Without a deliberate pause, Lobby (with the join code the host needs to
// read and share) would show and hide again within the same frame. This gives the
// host a real window to see and share it before Chat appears.
yield return new WaitForSeconds(HostLobbyDisplaySeconds);
}
else
{
new LoginRequest { displayName = displayName }.Send(relay);
}
ShowChat();
}
public void OnSendClicked()
{
if (messageInput == null || string.IsNullOrEmpty(messageInput.text)) return;
if (relay.IsHost)
{
// Same reasoning as above -- broadcast directly instead of routing through
// Send(), and echo locally since BroadcastToAll doesn't loop the message back to
// the host's own OnChatBroadcastReceived handler.
var ev = new ChatBroadcastEvent { senderName = displayName, text = messageInput.text };
relay.BroadcastToAll("ChatBroadcast", ev.Serialize(), true);
AppendLog($"{ev.senderName}: {ev.text}");
}
else
{
new SendChatRequest { text = messageInput.text }.Send(relay);
}
messageInput.text = "";
}
// Wired to the ChatBroadcast binding's "On Received" UnityEvent (Part 7.9), not a
// code-based Subscribe call -- it needs to be parameterless, so the actual payload is
// pulled via GetLastPayload() instead of arriving as an argument.
public void OnChatBroadcastReceived()
{
var ev = (ChatBroadcastEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("ChatBroadcast");
AppendLog($"{ev.senderName}: {ev.text}");
}
// How close to the bottom (0 = bottom, 1 = top, Unity's own ScrollRect convention) counts
// as "already reading the latest messages" for auto-scroll purposes.
private const float AutoScrollThreshold = 0.02f;
private void AppendLog(string line)
{
// Decide BEFORE appending: only auto-scroll if the user was already at (or very near)
// the bottom. Yanking the view down on every message would make it impossible to
// scroll up and read history -- confirmed live as the actual "scrolling doesn't work
// well" complaint, not just a missing nicety.
bool wasAtBottom = chatScrollRect == null || chatScrollRect.verticalNormalizedPosition <= AutoScrollThreshold;
chatLog.text += line + "\n";
if (wasAtBottom && chatScrollRect != null)
StartCoroutine(ScrollToBottomNextFrame());
Debug.Log("[ChatDemo] " + line);
}
// Confirmed live: setting verticalNormalizedPosition in the same frame the text changes
// does nothing useful -- Content Size Fitter/Layout Group haven't recalculated Content's
// height yet, so the ScrollRect is still working off the previous (smaller) bounds. One
// frame plus a forced canvas update is enough for the rebuilt layout to land first.
private IEnumerator ScrollToBottomNextFrame()
{
yield return null;
Canvas.ForceUpdateCanvases();
chatScrollRect.verticalNormalizedPosition = 0f;
}
}
A finished reference scene with all of the above already wired up lives at Assets/PivecLabs/Examples/ChatDemo/ChatDemoScene.unity, if you'd rather inspect a working example side by side with your own than build entirely from these steps alone.
Notice the relay.IsHost branches in SendLoginWhenConnected() and OnSendClicked(). RelayConnectionManager.Send() only has an implicit destination for joiners — on the host it's a documented no-op with a warning, because the host has no single "the" peer to send to (see API Reference). Any message the host's own player needs to send has to go through BroadcastToAll/SendToPeer directly instead of Send(), and since those don't loop back to the host's own event handlers, the host has to append its own message to the chat log itself rather than waiting for OnChatBroadcastReceived to fire. This tripped up the first version of this exact demo — worth remembering for any message your host player needs to originate, not just chat.
Part 8 — Run it#
- Build the scene (File → Build Settings → Build), or use two Editor instances if you have a license that supports it.
- Run one copy. Enter a name, click Continue — you land on the Lobby panel. Click Host Game. The Lobby panel stays up with your join code on
JoinCodeDisplayfor a few seconds (HostLobbyDisplaySecondsinChatDemoController.cs, shown above) — that's your window to read and share it — before it moves on to the Chat panel automatically. - Run a second copy (and a third, a fourth — this works the same at 2, 3, or 4 players). Enter a name, click Continue, type in the join code from step 2, click Join Game.
- Send a message from any copy. It should appear in every copy's chat log, prefixed with the sender's name — including copies that didn't send it, proving the host's
BroadcastToAllfan-out is working, and including on the host's own copy, since the host is also a player.
If something goes wrong instead — the relay is unreachable, or a join code doesn't resolve — StatusText on the Lobby panel now shows a plain-language message (e.g. "Couldn't reach the relay server — check your connection", or "That join code wasn't found — check it and try again") instead of leaving you looking at a Lobby panel with no explanation. A join that's still retrying briefly shows "Retrying connection… (attempt N of 3)" first. Full technical detail always still goes to the Console, unchanged.
If a message doesn't show up everywhere, check the Console on the host's copy first — HandleSendChat logs are the easiest place to see whether the host received it and re-broadcast it.
Common mistakes#
App hosts a session immediately on Play, before you've touched Login at all. Confirmed live: this happens when RelayConnectionManager's GameObject (Part 7.5) was left active in the Inspector instead of unchecked. Its Start() calls Connect() unconditionally, and Connect() defaults to hosting when no join code is pending — so an active RelayConnectionManager starts a real session within about a second of the scene loading, with no clicks at all. This is easy to miss because nothing else about the scene looks wrong: same hierarchy, same references, same script attachments. ChatDemoController.Awake() defensively forces this GameObject inactive every time as of this version, so this specific symptom shouldn't be reproducible anymore even if the checkbox is left wrong — but if you're seeing it anyway, that's the first thing to check.
Message doesn't show up on every copy. Check the Console on the host's copy first — HandleSendChat logs are the easiest place to see whether the host actually received the message and re-broadcast it, versus it never arriving at the host at all.
Chat scrolling doesn't work well — new messages don't come into view, or you can't scroll up to read history, even though messages are clearly still arriving. Confirmed live: this happens when the Content Size Fitter ends up on ChatLog instead of on Content (Part 7.4) — ChatLog grows to fit its own text just fine, but a ScrollRect measures its scrollable range from Content's size, not any child's, so Content never growing means the ScrollRect never thinks there's anything new to scroll to. Verify: select Content in the Hierarchy and confirm it has both a Vertical Layout Group and a Content Size Fitter (Vertical Fit = Preferred Size) — not ChatLog.
NetCrafter Relay — Pivec Labs