NetCrafter Relay Tutorial: Animated Multiplayer Character
Replicate a fully-animated Starter Assets Third Person character across every connected player, using six simultaneous NetCrafterSyncBinding rules per character.
Time: 75–100 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). Recommended (not required): read the Chat Quick Start and 2-4 Player Co-op Arena tutorials first — this tutorial reuses their Login/Lobby flow and sync-system concepts without re-explaining them from scratch.
What you'll build: A shared scene where every connected player controls Unity's Starter Assets Third Person character — walking, running, and jumping animations replicated live to every other client, plus a floating name tag over each remote player.
This tutorial exists to answer one question concretely: can a fully-animated third-person character replicate correctly through NetCrafter Relay, using the same NetCrafterSyncBinding/NetCrafterSyncManager system Co-op uses for position? Short answer: yes, as of this tutorial — but it took two small fixes to the base package to get there, both covered in Part 0. If you read this before Co-op: this is the more advanced of the two sync tutorials, not the simpler one — Co-op's single Transform_Position rule is the easier starting point.
Part 0 — What changed in the sync system to make this possible#
Co-op syncs exactly one property per object: position. A fully-animated character needs six — position, rotation, and four Animator parameters (Speed, MotionSpeed, Grounded, Jump) — all replicating simultaneously on the same GameObject. Building this tutorial surfaced two real gaps in the sync system as it stood after Co-op, both now fixed:
1. SyncTarget had no Animator option at all. Before this tutorial, SyncRuleDefinition.syncTarget only covered Transform_Position/Rotation/Scale and Rigidbody_Velocity/AngularVelocity — nothing that could read or write an Animator parameter. Two new enum values now exist: Animator_Float and Animator_Bool, plus a new SyncRuleDefinition.animatorParameterName field (set in the Sync Settings tab) naming exactly which Animator Controller parameter each rule targets.
2. NetCrafterSyncManager could only register one binding per object, ever. This one was subtler and would have silently broken this exact tutorial: the binding registry used to be keyed by objectId alone (Dictionary<int, NetCrafterSyncBinding>). Co-op never noticed, because each capsule only ever carried one binding. The moment a single character carries six bindings under the same objectId — one for position, one for rotation, four for Animator parameters — each binding's registration would have silently overwritten the previous one, so only the last-registered rule would ever actually receive incoming data on remote clients. The registry is now keyed by (ruleName, objectId) together, so multiple bindings on one object coexist correctly. See API Reference for the updated signatures.
Nothing about this changes how you use NetCrafterSyncBinding day to day — Configure(id, hasAuthority) still works exactly the same way per binding. It just means an object with multiple bindings needs Configure() called on each of them, not once for the object as a whole (Part 7 below does this in a loop with GetComponents<NetCrafterSyncBinding>(), not by hand).
Part 1 — Single-player setup#
This part has nothing to do with networking yet — it gets Unity's Starter Assets Third Person character moving and animating locally, exactly as Unity ships it. Everything here is unchanged by the relay model; skip ahead to Part 2 if you already have a working single-player Starter Assets scene.
1.1 Install Cinemachine and Starter Assets
- Window → Package Manager.
- Switch the source dropdown to Unity Registry, search
Cinemachine, select it, click Install. Do this before Starter Assets — the Starter Assets sample scene uses Cinemachine cameras and will show errors without it already present. - Starter Assets is not a Unity Registry package — it ships as a free Asset Store asset. Get it from the Unity Asset Store page for Starter Assets: Character Controllers | URP (or the older standalone Starter Assets - ThirdPerson | URP package): add it to your account from the web page, then in Unity's Package Manager switch the source dropdown to My Assets, find it in the list, and click Import.
This is different from how the original NetCrafter tutorial described it. The old instructions said Starter Assets was searchable directly under Package Manager's Unity Registry tab. As of writing, it isn't — it's an Asset Store asset that shows up under My Assets after you add it to your account from the web store, not a registry package you can search for by name inside the Editor. If your Package Manager's Unity Registry search comes up empty for "Starter Assets", this is why — use My Assets instead.
When you first add a TextMeshPro component to a scene, Unity prompts you to import TMP Essentials — click Import TMP Essentials when this appears (if you haven't already, per the prerequisites above).
1.2 Import NetCrafter Relay
If you haven't already, import the NetCrafter Relay package into this project (Assets → Import Package → Custom Package…, or however you obtained it from the Asset Store). Confirm Assets/PivecLabs/NetCrafterRelay/ exists in the Project window afterward.
1.3 Open the sample scene and save a working copy
- In the Project window, navigate to the imported Starter Assets sample scene (its exact path depends on which package variant you installed — look under a
StarterAssetsorThirdPersonController/Scenesfolder for a scene showing a character on a small platform/playground). - Open it, then File → Save As into
Assets/Scenes/CharacterScene.unity. Work in this copy from now on — don't modify the original sample scene.
1.4 Confirm single-player works
- Press Play.
- WASD to move, mouse to look, Space to jump. Confirm walk/run/jump animations play and the Cinemachine camera follows correctly.
- Press Stop.
The GameObject actually driving the character (Starter Assets typically names it PlayerArmature or similar — confirm the exact name in your imported version) has an Animator component whose Controller drives four parameters relevant to this tutorial: Speed (float), MotionSpeed (float), Grounded (bool), Jump (bool). Open the Animator window with this GameObject selected and confirm these four parameter names exactly — Part 6 depends on matching them exactly, character for character.
Everything above this line is completely relay-agnostic — if you already have a working single-player Starter Assets project, there was nothing relay-specific to change here. The interesting part starts now.
Part 2 — Create the schema#
This schema is already included with the package, fully built — you can skip ahead. Assets/PivecLabs/NetCrafterRelay/Definitions/CharacterDemo/ ships with everything Parts 2–6 below build from scratch: the three messages, the HandleLogin host action, all six sync rules, a filled-in RelaySettings asset, and already-generated client/host code — the exact schema the shipped CharacterScene.unity reference scene (Part 7) is built against. Tools → Pivec Labs → NetCrafter Relay, select CharacterDemo from the schema list (don't click + Create Definition — that would try to create a folder that already exists), and skip straight to Part 7 to build the scene using it. One thing to change even if you skip ahead: RelaySettings ships pointing at Pivec Labs' own test relay/rendezvous server — open Settings → Relay Settings and replace Relay Host, Rendezvous URL, and Shared Secret with your own (see the Server Deployment Guide) before treating this as your own working setup.
Prefer to build it yourself and learn how the schema system works? Follow Parts 2–6 below as written — nothing about them assumes the predefined version doesn't exist.
Tools → Pivec Labs → NetCrafter Relay. Click + Create Definition, enter CharacterDemo, click Create.
Part 3 — Define the messages#
Open the Gameplay Messages tab. Click Create Gameplay Message three times.
3.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).
3.2 PlayerJoined — host announces a player (new or already-known) to someone
| Field | Value |
|---|---|
| Message Name | PlayerJoined |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add two fields: playerId (type int), displayName (type string).
3.3 PlayerLeft — host announces a player disconnected
| Field | Value |
|---|---|
| Message Name | PlayerLeft |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add one field: playerId (type int).
Same lesson as Co-op: there's no message here for position, rotation, or animation state at all. All six of those replicate continuously through the sync system (Part 6), not the schema/message system — the three messages above only ever fire once each, at login/join/leave.
Part 4 — Define the Host Action#
Open the Host Actions tab. Create one.
4.1 HandleLogin
| Field | Value |
|---|---|
| Handler Name | HandleLogin |
| Trigger Message | Login |
| Response Event | PlayerJoined |
| Logic Type | Custom |
Custom Host Code (roster-replay pattern shared with the Co-op tutorial's HandleLogin — see there for the full explanation of why the replay loop exists — plus one line Co-op doesn't need, explained below):
string displayName = trigger.displayName;
foreach (var kv in CharacterGameState.PlayerNames)
{
var existing = new PlayerJoinedEvent { playerId = kv.Key, displayName = kv.Value };
connection.SendToPeer(senderId, "PlayerJoined", existing.Serialize(), true);
}
CharacterGameState.PlayerNames[senderId] = displayName;
var joined = new PlayerJoinedEvent { playerId = senderId, displayName = displayName };
connection.BroadcastToAll("PlayerJoined", joined.Serialize(), true);
// BroadcastToAll never loops back to the host's own incoming-message pipeline, so without
// this direct call the HOST never spawns a remote instance for a newly-joined player -- same
// "host can't rely on receiving its own broadcast" lesson as Co-op's RequestCollectPickup.
CharacterDemoController.Instance?.HostRegisterPlayerLocally(senderId, displayName);Without that last line, the host never sees any joiner's character. In a multi-player session, joiners correctly see each other's animated characters, but the host sees neither — no error, just empty space where their characters should have appeared. Co-op never needs this because its 4 avatar slots are pre-placed, not spawned on join; Character spawns remote instances dynamically, and the host is the one place PlayerJoined for someone else's login is never actually received as an incoming event (see CharacterDemoController.Instance and HostRegisterPlayerLocally in Part 7.5 for the other half of this fix).
4.2 The shared game-state helper
Assets/PivecLabs/Examples/CharacterDemo/Scripts/CharacterGameState.cs ships with the package — nothing to create here.
Show CharacterGameState.cs
using System.Collections.Generic;
public static class CharacterGameState
{
public static readonly Dictionary<int, string> PlayerNames = new();
// Called from CharacterDemoController.Awake() -- static state survives across repeated
// Unity Editor Play-mode sessions when Domain Reload is disabled.
public static void Reset()
{
PlayerNames.Clear();
}
}Keep this file outside Assets/PivecLabs/NetCrafterRelay/ — same assembly-isolation reasoning as CoopGameState/ChatPlayerRegistry. If you're building the Chat and/or Co-op tutorials in the same project, CharacterGameState doesn't collide with either of those — all three are distinctly named plain static classes compiling into the same Assembly-CSharp assembly.
Part 5 — Configure Relay Settings and generate#
Open Settings → Relay Settings and fill in your own coturn/rendezvous details (identical fields to the Chat and Co-op tutorials — Relay Host, Relay Port 3478, Rendezvous URL, Credential Mode StaticSecret, TURN Username, Shared Secret). Click Save Settings.
Open Build Code, click Validate Definition, then Generate All Files. This writes to Definitions/CharacterDemo/Generated/:
Client/LoginRequest.cs,Client/PlayerJoinedEvent.cs,Client/PlayerLeftEvent.cs,Client/RelayConnectionManager.csHost/HostActionRouter.csConfig/RelayConfig.json
Let Unity recompile and check the Console for CS0103: The name 'CharacterGameState' does not exist — if you see it, double-check Part 4.2's file exists.
Part 6 — Create the six sync rules#
Every rule below is a SyncRuleDefinition asset, created in Assets/PivecLabs/Examples/CharacterDemo/ via right-click → Create → PivecLabs → NetCrafterRelay → Sync Rule.
Asset Name and Rule Name are two different things, set two different ways. Asset Name is the filename Unity gives the new .asset file in the Project window — right after creating it, Unity puts the name into an editable text field right there in the Project window; type the Asset Name column's value into that field before doing anything else. Rule Name is a separate, actual field inside the asset, set afterward in the Inspector (along with Sync Target, Animator Parameter Name, and Sync Mode) — this is the name the runtime actually uses to key sync data, and it deliberately doesn't have to match the filename.
| Asset name | Rule Name | Sync Target | Animator Parameter Name | Sync Mode |
|---|---|---|---|---|
CharacterPositionSyncRule | CharacterPosition | Transform_Position | (n/a) | OnChange |
CharacterRotationSyncRule | CharacterRotation | Transform_Rotation | (n/a) | OnChange |
CharacterSpeedSyncRule | CharacterSpeed | Animator_Float | Speed | OnChange |
CharacterMotionSpeedSyncRule | CharacterMotionSpeed | Animator_Float | MotionSpeed | OnChange |
CharacterGroundedSyncRule | CharacterGrounded | Animator_Bool | Grounded | OnChange |
CharacterJumpSyncRule | CharacterJump | Animator_Bool | Jump | OnChange |
Create and rename one row at a time — don't create all six, then rename them. Right-click → Create → ... → Sync Rule drops the new asset straight into an editable rename field in the Project window; typing the Asset Name column's value there immediately, before creating the next one, is what keeps every asset's default name unique. Create a second (or third, fourth...) SyncRule asset while an earlier one is still sitting at its default, un-renamed name, and Unity silently appends " 1", " 2", etc. to keep filenames unique (SyncRule 1.asset, SyncRule 2.asset) — a real, easy-to-miss artifact if you click through the creation menu six times in a row before renaming anything. If you already have one of these, it's harmless to fix after the fact: select it in the Project window, press F2 (or double-click the name), and retype the correct Asset Name — nothing else about the asset changes.
For each row: create the asset, rename it to the Asset Name column's value, then select it and fill in Rule Name, Sync Target, Animator Parameter Name (where applicable), and Sync Mode in the Inspector to match that row — then move on to the next row.
Animator Parameter Name must exactly match the parameter names on your character's Animator Controller (confirmed in Part 1.4). A typo here doesn't error — Animator.GetFloat/GetBool on a nonexistent parameter name silently does nothing useful, and SetFloat/SetBool silently no-ops too. If a specific animation parameter isn't syncing but others are, this is the first thing to check.
You'll assign these six rules to six NetCrafterSyncBinding components in Part 7 — one set on the local player, another matching set on the remote character prefab.
Part 7 — Build the scene#
7.1 Add sync bindings to the local player
Select your character's root GameObject (PlayerArmature or equivalent, from Part 1.4) in CharacterScene. Six times: Add Component → NetCrafter → Sync Binding, then immediately drag one of Part 6's six rule assets onto that specific binding's Rule field before adding the next one (adding all six first and sorting out which rule goes where afterward is easy to mix up — do it one at a time).
Leave every added Sync Binding component's checkbox unchecked — CharacterDemoController (Part 7.5) enables all of them together, with the correct objectId/authority, right after login.
Leave Has Authority unchecked and Object Id at 0 on every one of these six components — don't set them here, for either the local player or (in Part 7.2) the remote prefab. Both fields are visible, editable Inspector fields, which invites "shouldn't the local player have authority? let me check this" — resist that. CharacterDemoController.cs's Configure(id, authority) sets both correctly at runtime, once login actually confirms this client's real player id and whether it's the host — that's the only place these values should ever come from. The concrete risk of setting them by hand isn't just "it'll get overwritten and be fine": Configure() only updates the two field values themselves: it does not rerun the component's one-time setup logic (subscribing to incoming network updates, disabling root motion for non-authoritative objects), which fires exactly once, in OnEnable(), using whatever Has Authority value is current at that moment. If a binding is ever enabled (checkbox checked, in the Editor or by accident) before Configure() has run, that one-time setup locks in based on the wrong value and never re-evaluates — for example, a local player binding could end up also subscribed to incoming sync data for its own property, so it visibly fights or snaps against whatever arrives over the network for that property, for the rest of the session. Leaving the checkbox unchecked (as instructed above) is what prevents this from happening at all — the two are a matched pair, not independent precautions.
Why six separate components instead of one that syncs everything? Each SyncRuleDefinition answers "what single property, synced how" — same design Co-op uses for position alone. A character with six properties to sync just means six of these small components side by side, not a different mechanism. See Part 0 for what had to change in the base package to make six components on one object actually work correctly.
7.2 Build the remote character prefab
Step 1 — Duplicate, unpack, and strip. Right-click your local player GameObject in the Hierarchy → Duplicate. Rename the duplicate RemoteCharacterPrefab. Then, before touching any components: right-click RemoteCharacterPrefab → Prefab → Unpack Completely.
Don't skip the unpack — this is a real, confirmed trap, not a defensive formality. Your local player GameObject (from Part 1.3's Starter Assets sample scene) is itself an instance of a prefab that ships inside the Starter Assets package. Duplicate doesn't break that link — it gives you a second instance of the same source prefab, not an independent copy. If you start removing/adding components on RemoteCharacterPrefab while it's still connected that way, and later (Part 7.2 Step 4) drag it into the Project window to save it as its own prefab, Unity may offer to save it as a Prefab Variant of that same Starter Assets base rather than a fully independent prefab — at which point RemoteCharacterPrefab and your local player instance are still, structurally, two views onto the same underlying source. Confirmed behavior in that state: component changes made while intending to edit only one of them can silently apply to the other instead (in either direction) — usually because double-clicking a linked instance in the Hierarchy opens Prefab Mode for the shared source asset itself, not an instance-local edit, and anything changed there propagates to every instance/variant of it, not just the one you meant to change. Unpack Completely severs the link outright, immediately, before any of that can happen — everything from here on (removing components, adding new ones, building NameCanvas) is then a plain, fully independent GameObject with zero prefab-instance machinery involved, and Step 4's "drag into the Project window" later creates a clean, standalone prefab with no dialog and no lingering connection to Starter Assets' own prefab.
Remove these components (right-click each → Remove Component) — they all either read local input or drive physics that would fight directly-assigned sync data:
- Third Person Controller
- Basic Rigid Body Push
- Starter Assets Inputs
- Player Input
Leave the Character Controller and Animator components on — NetCrafterSyncBinding writing transform.position directly works fine alongside an enabled CharacterController; no manual disabling needed.
Step 2 — Add remaining components. Add Component → search for Remote Animation Event Receiver → add it. Starter Assets animation clips fire OnFootstep/OnLand SendMessage events that normally play sounds via ThirdPersonController, which you just removed; without a receiver, Unity logs a Console warning every time either event fires. RemoteAnimationEventReceiver.cs ships with the package (Assets/PivecLabs/Examples/CharacterDemo/Scripts/) — nothing to create, it's just a small stub that swallows both events:
Show RemoteAnimationEventReceiver.cs
using UnityEngine;
public class RemoteAnimationEventReceiver : MonoBehaviour
{
// Empty on purpose -- Starter Assets' animation clips SendMessage these two event names
// during footstep/landing frames. ThirdPersonController normally handles them (playing a
// sound); it's removed from RemoteCharacterPrefab, so without these stubs Unity logs a
// "SendMessage: no receiver" Console warning every time either animation event fires.
public void OnFootstep(AnimationEvent animationEvent) { }
public void OnLand(AnimationEvent animationEvent) { }
}PlayerNameDisplay is added in Step 3 below, on NameCanvas — not here on RemoteCharacterPrefab itself. See the warning in Step 3 for exactly why that distinction matters.
The six NetCrafterSyncBinding components already exist on RemoteCharacterPrefab — they were duplicated along with everything else in Step 1, rules and all. Leave every one of them unchecked, same as the local player — CharacterDemoController configures and enables all six the moment this prefab is spawned for an actual remote player (Part 7.5).
Step 3 — Name tag canvas. Right-click RemoteCharacterPrefab → UI → Canvas. Name it NameCanvas.
- Canvas component: Render Mode → World Space.
- Transform: Position
(0, 2.2, 0), Rotation(0, 0, 0), Scale(0.01, 0.01, 0.01)— the default Canvas scale is roughly 100× too large for world space;0.01brings it to the right size relative to the character. - Rect Transform: Width
200, Height50.
Right-click NameCanvas → UI → Text - TextMeshPro. Name it NameText. Anchor/stretch to fill the canvas (all offsets 0). On the TMP_Text component: Alignment Center/Middle, Font Size 36, Color white, leave the text itself empty (set at runtime).
With NameCanvas selected (not RemoteCharacterPrefab), Add Component → search for Player Name Display → add it. Drag NameText into its Name Text field. PlayerNameDisplay.cs ships with the package (Assets/PivecLabs/Examples/CharacterDemo/Scripts/) — nothing to create, for reference this is what it contains:
Show PlayerNameDisplay.cs
using UnityEngine;
using TMPro;
public class PlayerNameDisplay : MonoBehaviour
{
public TMP_Text nameText;
private Transform _cam;
public void SetName(string displayName)
{
if (nameText != null) nameText.text = displayName;
}
private void LateUpdate()
{
if (_cam == null)
{
if (Camera.main == null) return;
_cam = Camera.main.transform;
}
// Billboard -- face the tag toward whichever camera is currently active so it always
// reads correctly regardless of which direction the character (and its Cinemachine
// follow rig) is currently facing.
transform.forward = transform.position - _cam.position;
}
}PlayerNameDisplay must live on NameCanvas, never on RemoteCharacterPrefab's root. Its LateUpdate() billboards the name tag toward the camera by rotating its own transform directly (transform.forward = transform.position - camera.position) — there's no scoping to "just the name tag" built into that line, it rotates whatever GameObject the component is actually attached to. Put it on the root by mistake (an easy slip, since the root is what's selected right after Step 1's duplicate-and-strip, and NameCanvas doesn't exist yet at that point) and it rotates the entire character to face the camera every frame, fighting the six NetCrafterSyncBinding components that are trying to apply the real, synced rotation to that same root Transform. The visible symptom is exactly this: remote characters never turn to face their movement direction — they face the camera instead, regardless of which way they're actually moving, and it looks like a sync bug even though sync is working correctly underneath. This exact mistake was made once already in NetCrafter's own equivalent tutorial. CharacterDemoController.cs's spawn code already looks the component up with GetComponentInChildren<PlayerNameDisplay>(), not GetComponent<PlayerNameDisplay>(), specifically so it doesn't care which child PlayerNameDisplay lives on — but the component still has to be on NameCanvas for the rotation itself to only affect the name tag.
Step 4 — Save and remove from the Hierarchy. Drag RemoteCharacterPrefab from the Hierarchy into Assets/PivecLabs/Examples/CharacterDemo/Prefabs/RemoteCharacterPrefab.prefab, then delete it from the Hierarchy — it's now a prefab asset, spawned at runtime instead of pre-placed.
7.3 Build the UI
- Canvas (Screen Space - Overlay) — name it
UICanvas. - LoginPanel: TMP Text (
Title, "Character Demo"), TMP Input Field (DisplayNameInput, placeholder "Enter your name..."), TMP Button (ContinueButton, "Continue"). - LobbyPanel (unchecked by default): TMP Button (
HostButton, "Host Game"), TMP Input Field (JoinCodeInput, placeholder "Join code..."), TMP Button (JoinButton, "Join Game"), TMP Text (JoinCodeDisplay, blank), TMP Text (StatusText, blank — shows connection errors/retry status; kept separate fromJoinCodeDisplayso success and error text never share one field). - GameplayPanel (unchecked by default, Image alpha
0— gameplay is the 3D scene behind it): TMP Text (ConnectedPlayersText, blank, anchored top-left) — a simple always-visible roster, separate from the floating name tags on remote characters themselves.
7.4 Create the RelayConnectionManager prefab and add the relay/event GameObjects
Open Scenes → Prefab Generator in the NetCrafter Relay window, confirm CharacterDemo is the selected schema, click Create RelayConnectionManager Prefab. This writes RelayConnectionManager.prefab to Definitions/CharacterDemo/Prefabs/ (a sibling of Generated/, not inside it — this prefab holds your actual relay settings once configured, so it's never touched by code regeneration) — do this before the Scene Assistant step below, so the connection-manager GameObject it adds to the scene comes from this prefab rather than a raw, disconnected GameObject. (Scene Assistant falls back to creating one either way if you skip this — nothing breaks — but building from the prefab is the intended path and keeps the scene object linked to a reusable asset.)
Open Scenes → Scene Assistant, confirm CharacterDemo is still the selected schema, confirm Add Event Dispatcher is checked, click Add to Existing Scene. This adds:
RelayConnectionManager— an instance of the prefab you just created (CharacterDemo's generated connection type), added already disabled (same defensive reason as Chat/Co-op — see the warning in either tutorial if this is new to you).HostActionRouter—CharacterDemo's generated host-side logic dispatcher.NetCrafterEventDispatcher.NetCrafterSyncManager— added automatically becauseCharacterDemohasSyncRuleDefinitionassets (Part 6).
Then: Settings → Scene Events → Add Router to Scene — creates a SceneEventRouter GameObject listing PlayerJoined and PlayerLeft, each with an empty On Received slot (wired in 7.5).
7.5 Attach and wire CharacterDemoController
Assets/PivecLabs/Examples/CharacterDemo/Scripts/CharacterDemoController.cs ships with the package:
Show CharacterDemoController.cs
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using TMPro;
using StarterAssets;
using PivecLabs.Generated.Character;
using PivecLabs.NetCrafterRelay;
public class CharacterDemoController : MonoBehaviour
{
// Referenced by HandleLogin's Custom Host Code (see HostRegisterPlayerLocally below) --
// the host process needs a way to call back into its own running scene, since
// BroadcastToAll never loops back to the sender's own incoming-message pipeline.
public static CharacterDemoController Instance { get; private set; }
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 gameplayPanel;
public TMP_Text connectedPlayersText;
public GameObject localPlayerArmature;
public GameObject remoteCharacterPrefab;
private RelayConnectionManager relay;
private string displayName = "Player";
private NetCrafterSyncBinding[] _localBindings;
private readonly Dictionary<int, GameObject> _remotePlayers = new();
// Starter Assets' own cursor-lock handling (StarterAssetsInputs.OnApplicationFocus) locks
// the cursor the moment the game window gains focus -- including on the very first frame,
// long before ShowGameplay() ever runs -- which blocks clicking into displayNameInput/
// joinCodeInput entirely (a locked cursor can't click a TMP_InputField). Cached here so
// panel switches can drive the cursor explicitly instead of leaving it at that default.
private StarterAssetsInputs _starterAssetsInputs;
private void Awake()
{
Instance = this;
CharacterGameState.Reset();
relay = relayConnectionManagerObject.GetComponent<RelayConnectionManager>();
relayConnectionManagerObject.SetActive(false);
_localBindings = localPlayerArmature.GetComponents<NetCrafterSyncBinding>();
_starterAssetsInputs = localPlayerArmature.GetComponent<StarterAssetsInputs>();
ShowLogin();
}
private void Start()
{
NetCrafterEventDispatcher.Instance.Subscribe("RelayHosting", OnRelayHosting);
NetCrafterEventDispatcher.Instance.Subscribe("ConnectionLost", OnConnectionLost);
NetCrafterEventDispatcher.Instance.Subscribe("RelayStatus", OnRelayStatus);
}
// Confirmed live: StarterAssetsInputs has no Awake()/Start() of its own at all -- the only
// place it ever applies Cursor.lockState is OnApplicationFocus, using whatever
// cursorLocked happens to hold at that moment. That makes the cursor's actual state a
// function of focus-change timing (alt-tab, clicking between two local test instances,
// the very first frame entering Play Mode) rather than something the one-time
// SetGameplayCursorLocked() calls below can fully guarantee on their own. Rather than
// chase every individual race, this re-asserts the correct state every frame instead of
// only at the three panel-transition points -- a stale lock (or a stale unlock) can never
// persist past one frame, regardless of what reapplied it or when.
private void Update()
{
bool shouldBeLocked = gameplayPanel != null && gameplayPanel.activeSelf;
if (Cursor.visible == shouldBeLocked)
SetGameplayCursorLocked(shouldBeLocked);
}
private void ShowLogin() { loginPanel.SetActive(true); lobbyPanel.SetActive(false); gameplayPanel.SetActive(false); SetGameplayCursorLocked(false); }
private void ShowLobby() { loginPanel.SetActive(false); lobbyPanel.SetActive(true); gameplayPanel.SetActive(false); SetGameplayCursorLocked(false); }
private void ShowGameplay() { loginPanel.SetActive(false); lobbyPanel.SetActive(false); gameplayPanel.SetActive(true); SetGameplayCursorLocked(true); }
// Keeps the cursor free and visible (so displayNameInput/joinCodeInput can actually be
// clicked into and typed in) while a UI panel is showing, and locked for Cinemachine's
// camera-look controls once Gameplay actually starts. Also updates StarterAssetsInputs'
// own cursorLocked/cursorInputForLook fields, not just Cursor.lockState directly -- those
// fields are what OnApplicationFocus re-applies on the next focus change (e.g. alt-tab away
// and back while still on the Login/Lobby panel); without updating them too, refocusing the
// window would silently re-lock the cursor out from under an open panel.
private void SetGameplayCursorLocked(bool locked)
{
Cursor.lockState = locked ? CursorLockMode.Locked : CursorLockMode.None;
Cursor.visible = !locked;
if (_starterAssetsInputs != null)
{
_starterAssetsInputs.cursorLocked = locked;
_starterAssetsInputs.cursorInputForLook = locked;
}
}
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)
{
joinCodeDisplay.text = "Join code: " + (string)joinCodeObj;
StartCoroutine(SendLoginWhenConnected());
}
private void OnConnectionLost(object reason)
{
Debug.Log("[CharacterDemo] Connection lost: " + reason);
// Additive.
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) { Debug.LogError($"[CharacterDemo] Failed to connect within {ConnectTimeoutSeconds}s."); yield break; }
if (relay.IsHost)
{
CharacterGameState.PlayerNames[relay.LocalUserId] = displayName;
relay.OnPeerDisconnected += OnHostDetectedPeerDisconnected;
yield return new WaitForSeconds(HostLobbyDisplaySeconds);
}
else
{
new LoginRequest { displayName = displayName }.Send(relay);
}
ConfigureLocalPlayer();
RefreshPlayerList();
ShowGameplay();
}
// Enables and configures all six sync bindings on the local player object -- one call
// covers position, rotation, and all four Animator parameters, since Configure() means
// the same thing on every one of them ("you're player N, and you drive this one").
private void ConfigureLocalPlayer()
{
int myId = relay.LocalUserId;
foreach (var binding in _localBindings)
{
binding.Configure(myId, true);
binding.enabled = true;
}
}
private void OnHostDetectedPeerDisconnected(int peerId)
{
var left = new PlayerLeftEvent { playerId = peerId };
relay.BroadcastToAll("PlayerLeft", left.Serialize(), true);
OnPlayerLeftReceived(peerId); // BroadcastToAll doesn't loop back to the host itself
}
public void OnPlayerJoinedReceived()
{
var ev = (PlayerJoinedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PlayerJoined");
CharacterGameState.PlayerNames[ev.playerId] = ev.displayName;
if (ev.playerId != relay.LocalUserId)
SpawnOrUpdateRemote(ev.playerId, ev.displayName);
RefreshPlayerList();
}
// Called directly from HandleLogin's Custom Host Code -- only on the process actually
// hosting the session, and only for a player OTHER than the host itself (the host's own
// login is registered directly in SendLoginWhenConnected's host branch above). Without
// this direct call the host would never spawn a remote instance for its own joiners --
// see Part 4.1's warning for what this looked like live before the fix.
public void HostRegisterPlayerLocally(int playerId, string displayNameForPlayer)
{
CharacterGameState.PlayerNames[playerId] = displayNameForPlayer;
if (playerId != relay.LocalUserId)
SpawnOrUpdateRemote(playerId, displayNameForPlayer);
RefreshPlayerList();
}
private void SpawnOrUpdateRemote(int playerId, string displayNameForRemote)
{
if (_remotePlayers.TryGetValue(playerId, out var existing))
{
existing.GetComponentInChildren<PlayerNameDisplay>()?.SetName(displayNameForRemote);
return;
}
var instance = Instantiate(remoteCharacterPrefab);
foreach (var binding in instance.GetComponents<NetCrafterSyncBinding>())
{
binding.Configure(playerId, false);
binding.enabled = true;
}
instance.GetComponentInChildren<PlayerNameDisplay>()?.SetName(displayNameForRemote);
_remotePlayers[playerId] = instance;
}
public void OnPlayerLeftReceived(int playerId)
{
CharacterGameState.PlayerNames.Remove(playerId);
if (_remotePlayers.TryGetValue(playerId, out var instance))
{
Destroy(instance);
_remotePlayers.Remove(playerId);
}
RefreshPlayerList();
}
public void OnPlayerLeftBroadcastReceived()
{
var ev = (PlayerLeftEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PlayerLeft");
OnPlayerLeftReceived(ev.playerId);
}
private void RefreshPlayerList()
{
var sb = new StringBuilder();
foreach (var kv in CharacterGameState.PlayerNames)
sb.AppendLine(kv.Value);
if (connectedPlayersText != null) connectedPlayersText.text = sb.ToString();
}
}This also handles a real conflict, not just panel visibility: Starter Assets locks the cursor (for Cinemachine's camera-look controls) the moment the game window gains focus, which blocks clicking into displayNameInput/joinCodeInput at all — a locked cursor can't click a TMP_InputField. SetGameplayCursorLocked() above keeps the cursor free while Login/Lobby is showing and only locks it once Gameplay actually starts, so this isn't something you need to work around yourself — text entry and camera control each just work when they're supposed to.
Create an empty GameObject named CharacterDemoController, drag the script onto it, and wire:
| 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 |
| Gameplay Panel | GameplayPanel |
| Connected Players Text | ConnectedPlayersText |
| Local Player Armature | your Starter Assets character GameObject (Part 7.1) |
| Remote Character Prefab | RemoteCharacterPrefab.prefab |
Wire the buttons and events:
ContinueButton→ On Click () →CharacterDemoController.OnLoginClicked()HostButton→ On Click () →CharacterDemoController.OnHostClicked()JoinButton→ On Click () →CharacterDemoController.OnJoinClicked()SceneEventRouter'sPlayerJoinedbinding → On Received →CharacterDemoController.OnPlayerJoinedReceived()SceneEventRouter'sPlayerLeftbinding → On Received →CharacterDemoController.OnPlayerLeftBroadcastReceived()
Same RelayConnectionManager-starts-inactive lesson as Chat/Co-op applies here — Scene Assistant already created it disabled (Part 7.4), and CharacterDemoController.Awake() forces it inactive again defensively, so this shouldn't come up unless the GameObject was built by hand instead.
Part 8 — Run it#
- Enable Run In Background (Edit → Project Settings → Player → Resolution and Presentation) so each Editor instance keeps its relay connection alive while unfocused.
- Run one copy — the host. Click into the name field and type — the cursor should be a normal, free, visible cursor here, not locked/hidden (
CharacterDemoControllerkeeps it that way for as long as Login/Lobby is showing; see Part 7.5). Continue, Host Game. Move around with WASD, jump with Space — confirm your own animations still play locally exactly as in Part 1.4, and that the cursor is now locked for camera look (nothing about hosting changes your own local experience otherwise). - Run 2 more copies — joiners. Enter a name, Continue, type the join code, Join Game.
- On every client, confirm:
- Every other connected player's character appears with a floating name tag showing their display name.
- Walking, running, and jumping on any one client's own character shows the correct animation — not just a moved position, the actual walk/run/jump animation state — on every other client's screen, in near-real time.
- The
ConnectedPlayersTextpanel lists every connected display name on every client.
- Quit one joiner. After a short delay (host-side peer-timeout detection), that character should disappear (with its name tag) on every remaining client.
If avatars move but never animate on remote screens, or vice versa, see Common Mistakes below before assuming the network is broken.
If hosting or joining fails outright — relay unreachable, join code not found — StatusText on the Lobby panel now shows a plain-language message instead of a silent, unexplained Lobby screen (full detail still goes to the Console, unchanged). A join that's still retrying briefly shows "Retrying connection… (attempt N of 3)" first.
This full flow — host + 2 joiners, all six sync rules, real join code, live coturn/rendezvous relay — was verified end to end while writing this tutorial, using an automated test harness (a capsule + a minimal test AnimatorController standing in for the actual Starter Assets character, driven by a script instead of a keyboard) rather than the real Starter Assets package, since that package requires an interactive Asset Store login this pass didn't have access to. Every synced property (position, rotation, Speed, MotionSpeed, Grounded, Jump) confirmed replicating correctly and in real time between all three peers. The one part of this tutorial not verified end to end with the real Starter Assets character/Animator Controller specifically is exactly the substitution above — the Animator parameter names in Part 1.4/Part 6 are carried over from the original NetCrafter tutorial's own field list, not independently re-confirmed against a freshly-imported Starter Assets package this pass.
Common mistakes#
Positions sync but animations never do (or the reverse). Confirmed live during this tutorial's own testing as the very first failure mode encountered — before the Part 0 fixes, only ever exactly ONE of the six sync rules on a given character actually delivered data to remote clients, because NetCrafterSyncManager's registry used to key purely by objectId and each new binding's registration silently clobbered the last. If you're running an older copy of the base package without the Part 0 fixes, this is almost certainly why. On a current copy, if this still happens, check that all six Sync Binding components on both the local player and RemoteCharacterPrefab actually have a Rule assigned (Part 7.1/7.2) — an unassigned rule self-disables with a Console warning, same failure mode as Co-op's single rule.
An Animator parameter never changes on remote clients even though its Sync Binding is enabled and has a Rule assigned. Double check Animator Parameter Name on that specific SyncRuleDefinition (Part 6) against the actual parameter name in the Animator Controller (Part 1.4) — character for character, case-sensitive. A mismatch doesn't error; Animator.GetFloat/SetFloat (and the Bool equivalents) silently no-op on a name that doesn't exist on that Animator.
The mouse cursor disappears at an unexpected moment (not entering Gameplay) and only Escape brings it back. Confirmed live — StarterAssetsInputs has no Awake()/Start() of its own; the only place it ever applies Cursor.lockState is OnApplicationFocus, using whatever cursorLocked happens to hold at that exact moment. That makes a focus change (alt-tab, clicking between two local test instances, even the very first frame entering Play Mode) a point where the cursor's actual state can drift out of sync with whichever panel is really showing. CharacterDemoController.Update() re-asserts the correct state (unlocked/visible outside Gameplay, locked/hidden during it) every single frame instead of relying solely on the three panel-transition calls, so any stale lock or stale unlock self-corrects within one frame no matter what caused it.
Joiners see each other's characters fine, but the host sees no one. Confirmed live during this tutorial's own testing — this is exactly what happens without Part 4.1/7.5's HostRegisterPlayerLocally call. BroadcastToAll never loops back to the host's own incoming-message pipeline, so the host's OnPlayerJoinedReceived() (wired to the incoming PlayerJoined scene event) never fires for anyone — the host has to be told about its own joiners directly, not through the same event path everyone else uses. If you're seeing this, double-check CharacterDemoController.Instance exists and HandleLogin's Custom Host Code ends with the HostRegisterPlayerLocally line.
NullReferenceException clicking Host Game or Join Game. Same cause as Chat/Co-op: the RelayConnectionManager GameObject's component is a different schema's generated class, most likely from copying a GameObject/prefab out of the Chat or Co-op tutorial's scene instead of building this one via Scene Assistant. Delete it and re-add via Scenes → Scene Assistant → Add to Existing Scene.
A remote character's animations look frozen or snap between poses instead of blending smoothly. This is expected, not a bug — like Co-op's position sync, values are applied directly (Animator.SetFloat/SetBool, transform.position =/transform.rotation =) with no interpolation or smoothing layered on top. At typical join-code relay latencies this reads as slightly stepped rather than perfectly fluid; smoothing remote Animator/Transform values between updates is a reasonable enhancement to build on top of this tutorial, not something the base sync system does for you.
Remote characters move correctly but never turn to face their movement direction — they always face the camera instead. PlayerNameDisplay is on RemoteCharacterPrefab's root instead of on NameCanvas (Part 7.2, Step 3). Its LateUpdate() billboards whatever transform it's actually attached to; on the root, that's the whole character, and it fights the Transform_Rotation NetCrafterSyncBinding every frame — the sync system is working correctly, it's just losing the fight. Fix: in the Hierarchy, select RemoteCharacterPrefab's PlayerNameDisplay component and check which GameObject it's on. If it's the root, remove it and re-add it to NameCanvas instead (drag NameText back into the Name Text field afterward). This exact mistake was made once already in NetCrafter's own equivalent tutorial.
NetCrafter Relay — Pivec Labs