NetCrafter Relay Tutorial: 2-4 Player Co-op Arena
The flagship tutorial: a shared arena with real-time position sync, pickups, a hazard, and host-authoritative scoring for 2-4 players.
Time: 90–120 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). Recommended (not required): read the Chat Quick Start first — this tutorial reuses its Login/Lobby flow and several of its lessons without re-explaining them from scratch.
What you'll build: A small shared arena — up to 4 players moving around at once, 8 glowing pickups worth one point each, a rotating hazard that knocks you back to your spawn point, and a host-authoritative scoreboard everyone sees update in real time.
This is the flagship tutorial — the full gameplay pattern, not just a working pipeline proof. It builds on everything the Chat tutorial covers (schema creation, code generation, the event-dispatcher/scene-event-router split, the host-relay-only architecture, the "RelayConnectionManager starts inactive" defensive pattern) and adds the two things Chat deliberately doesn't need: continuous real-time position sync for every player's avatar, and host-authoritative validation of a gameplay action (collecting a pickup) so it can never be double-counted no matter which client's message arrives first. Built entirely from Unity primitives (capsules, cubes, spheres, basic materials, one particle effect) — no external assets, no import friction, no licensing questions.
00 — Architecture: host-relay-only, host-authoritative#
Same transport model as Chat: every peer — host and joiners alike — opens one TURN allocation on your coturn relay and talks through it. Joiners cannot reach each other directly; every message routes through whichever player is hosting, and the host fans results out to everyone 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.
Co-op adds two things on top of that:
Continuous position sync. Chat has no world state to keep in sync — a chat log is just a growing list of messages. Here, every player's avatar needs to visibly move on every other client's screen in real time. That's NetCrafterSyncBinding + NetCrafterSyncManager (covered in Part 6) — a small piece of the base package, separate from the schema/message system, that continuously replicates a Transform's position.
Host-authoritative scoring. Two players could walk onto the same pickup within the same network tick. If each client just locally decided "I touched it, +1 point" and told the host to broadcast that, a slow or lucky race could double-count a single pickup, or two different players could both get credit for it. Instead, every collection attempt — including the host's own — funnels through one piece of host-side logic (CoopGameState.TryCollectPickup, Part 3) that is the only place allowed to decide "has this pickup already been collected" and hands out credit exactly once. The host then broadcasts the outcome; nobody else's opinion about whether they collected something matters until the host confirms it.
This same "host as the single point everything passes through" idea from Chat applies here too, just with real consequences if you get it wrong: a message that skips the host (or logic that trusts the sender's claim instead of the host's decision) is exactly how double-counting bugs happen in host-authoritative games.
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 CoopDemo, click Create. This creates Assets/PivecLabs/NetCrafterRelay/Definitions/CoopDemo/ with all the standard subfolders and a fresh RelaySettings.asset with no working defaults, same as any new schema.
CoopDemo gets its own namespace, separate from ChatDemo's, the moment you create it (Settings → Project Settings → Namespace, pre-filled per schema). If you're building both tutorials in the same project, this is what lets both schemas generate a class named RelayConnectionManager (among others) with zero collisions — C# resolves them by fully-qualified namespace, not bare name. There is no guard-define pattern to add here (unlike NetCrafter's own #if NETCRAFTER_CHAT_EXAMPLE convention for its own bundled examples) — that pattern exists for a different problem (shared source between examples compiled together with feature flags), not this one.
Part 2 — Define the messages#
Open the Gameplay Messages tab. Click Create Gameplay Message five 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 CollectPickup — client asks the host to credit a pickup
| Field | Value |
|---|---|
| Message Name | CollectPickup |
| Direction | ClientToHost |
| Transport | Reliable |
| Host Handler Name | HandleCollectPickup |
Add one field: pickupId (type int).
2.3 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).
2.4 PickupCollected — host announces a pickup was credited
| Field | Value |
|---|---|
| Message Name | PickupCollected |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add three fields: pickupId (type int), collectorPlayerId (type int), newScore (type int).
2.5 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).
Notice there's no message for position updates. Real-time movement doesn't go through the schema's message system at all — it uses a separate, always-on sync mechanism covered in Part 6. The five messages above are for discrete events (someone logged in, someone scored, someone left); continuous position is a different problem with a different, purpose-built solution.
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 | PlayerJoined |
| Logic Type | Custom |
Custom Host Code:
string displayName = trigger.displayName;
// Tell the new joiner about every player already in the session -- PlayerJoined only ever
// fires once, at each player's own login, so without this replay a late joiner's local
// CoopGameState.PlayerNames would never learn earlier players' display names (scores stay
// correct either way, but their names would show a generic "PlayerN" fallback forever).
foreach (var kv in CoopGameState.PlayerNames)
{
var existing = new PlayerJoinedEvent { playerId = kv.Key, displayName = kv.Value };
connection.SendToPeer(senderId, "PlayerJoined", existing.Serialize(), true);
}
CoopGameState.PlayerNames[senderId] = displayName;
var joined = new PlayerJoinedEvent { playerId = senderId, displayName = displayName };
connection.BroadcastToAll("PlayerJoined", joined.Serialize(), true);Why the roster replay? PlayerJoined broadcasts once, when that player logs in. A player who joins third only ever hears about themself unless the host explicitly replays everyone who logged in before them — SendToPeer (not BroadcastToAll) sends that replay to just the new joiner, since nobody else needs to hear about players they already know about.
3.2 HandleCollectPickup
| Field | Value |
|---|---|
| Handler Name | HandleCollectPickup |
| Trigger Message | CollectPickup |
| Response Event | PickupCollected |
| Logic Type | Custom |
Custom Host Code:
if (CoopGameState.TryCollectPickup(trigger.pickupId, senderId, out int newScore))
{
var result = new PickupCollectedEvent { pickupId = trigger.pickupId, collectorPlayerId = senderId, newScore = newScore };
connection.BroadcastToAll("PickupCollected", result.Serialize(), true);
}Why the if? CoopGameState.TryCollectPickup (Part 3.3) is the dedup guard — it returns false for a pickup id that's already been collected. If it returns false here, nothing is broadcast at all: the requester (who may have already optimistically hidden the pickup locally, see Part 7) simply doesn't get a confirmation, and no other client hears about a "collection" that didn't actually happen. This is what makes double-counting structurally impossible rather than just unlikely — the only code path that can ever emit a PickupCollected broadcast is guarded by this one dedup check, for every requester, every time, including the host's own local pickups (Part 7's RequestCollectPickup, which calls this same static method directly instead of routing a message to itself — see the warning in Part 7.8).
3.3 Add the shared game-state helper
Both Host Actions above reference CoopGameState, a small static helper that holds the two pieces of state a session needs: who's connected, and which pickups have been collected. Create it now — Assets/PivecLabs/Examples/CoopDemo/Scripts/CoopGameState.cs:
using System.Collections.Generic;
public static class CoopGameState
{
public static readonly Dictionary<int, string> PlayerNames = new();
private static readonly HashSet<int> _collectedPickups = new();
private static readonly Dictionary<int, int> _scores = new();
// Host-authoritative: the one place that decides "has this pickup already been collected"
// and "what is this player's score now". Called from BOTH the generated HandleCollectPickup
// host action (for a joiner's pickup) AND the scene controller's own host-branch (for the
// host's own pickups). Having exactly one method mutate this state is what prevents
// double-counting between those two call sites.
public static bool TryCollectPickup(int pickupId, int playerId, out int newScore)
{
if (!_collectedPickups.Add(pickupId))
{
newScore = _scores.TryGetValue(playerId, out var existing) ? existing : 0;
return false;
}
_scores.TryGetValue(playerId, out var current);
newScore = current + 1;
_scores[playerId] = newScore;
return true;
}
// Called from CoopDemoController.Awake() -- static state survives across repeated Unity
// Editor Play-mode sessions when Domain Reload is disabled, a common performance setting.
// Standalone builds always start a fresh process, so this only matters for repeated
// in-Editor testing.
public static void Reset()
{
PlayerNames.Clear();
_collectedPickups.Clear();
_scores.Clear();
}
}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/CoopDemo/Scripts/ (no .asmdef in this folder) compiles into Assembly-CSharp alongside the generated code, same reasoning as Chat's ChatPlayerRegistry.
This is a plain static class, not a MonoBehaviour — nothing to attach, nothing to drag in the Inspector. If you're building both the Chat and Co-op tutorials in the same project, notice this is named CoopGameState, not ChatPlayerRegistry — both examples' scripts compile into the same Assembly-CSharp assembly, so plain (non-namespaced) helper class names must not collide between them. This is the only place that collision risk actually exists in the whole project; the generated networking code itself never collides, because it's namespaced per schema.
You'll see CoopGameState referenced again later, in Part 7.8's CoopDemoController.cs — there's nothing further to do there either; by the time you reach it, this file already exists and that's the only place it's used besides the two Host Actions above.
Part 4 — Configure Relay Settings#
Open Settings → Relay Settings. Every field is blank by default, same as Chat's — no working defaults, no Pivec Labs-run relay to fall back to.
| Field | Value |
|---|---|
| Relay Host | Your own coturn server's public IP or hostname |
| Relay Port | 3478 |
| Rendezvous URL | Your own rendezvous service's address — same host:port format as Relay Host above (a missing http:// is filled in automatically at runtime) |
| Credential Mode | StaticSecret |
| TURN Username | Must match user=<name>:<secret> in your turnserver.conf |
| Shared Secret | The same secret from that same line |
If you haven't stood up coturn and the rendezvous service yet, see the Server Deployment Guide first.
Click Save Settings.
Part 5 — Generate#
Open Build Code, click Validate Definition, then Generate All Files.
This writes to Definitions/CoopDemo/Generated/:
Client/LoginRequest.cs,Client/CollectPickupRequest.cs,Client/PlayerJoinedEvent.cs,Client/PickupCollectedEvent.cs,Client/PlayerLeftEvent.cs,Client/RelayConnectionManager.csHost/HostActionRouter.csConfig/RelayConfig.json
Let Unity recompile, then check the Console. If you see CS0103: The name 'CoopGameState' does not exist in the current context, double-check Part 3.3's file exists.
If you're also building the Chat tutorial in this same project: generating one schema's code never touches the other schema's already-generated files — each schema's Generate All Files only ever writes to (and cleans) its own Definitions/{Name}/Generated/ folder. Both schemas' generated code coexists in Assembly-CSharp at the same time with zero naming conflicts, since each is namespaced per schema (Part 1.2).
Part 6 — Understand the sync system#
Before building the scene, here's the one genuinely new idea Co-op needs that Chat didn't.
The problem, concretely
In Part 7 you'll build 4 player capsules. If they only had ordinary Unity movement code on them (read WASD, move transform.position) and nothing else, here's exactly what would happen when you test it: press W on your own copy of the game, and your own capsule moves — but on every other connected copy, that same capsule just sits there, frozen. Moving transform.position only ever changes it on the one machine running that line of code; nothing about it tells any other machine anything happened. Multiply that by 4 players and everyone sees only themselves move.
Three small pieces (all part of the base package — none of them generated per-schema, none of them written by you) fix this:
SyncRuleDefinition— a tiny asset that just answers "what do I sync, and how often?" For this tutorial: position, sent whenever it actually changes. You create exactly one of these in Part 7.6 and reuse it for all 4 capsules.NetCrafterSyncBinding— goes on each capsule. It's the part that actually sends (if this is your capsule) or receives (if it's someone else's).NetCrafterSyncManager— one per scene, needs no configuration. It's the switchboard that knows which incoming position update belongs to which capsule.
The most common way this silently fails: NetCrafterSyncBinding self-disables — with only a Console warning, not an error — if its SyncRuleDefinition field is left empty ("No SyncRuleDefinition assigned... Disabling."). Nothing else about the scene looks broken when this happens: it compiles, it runs, your own capsule still moves fine on your own screen. Confirmed live during this tutorial's own testing, this was the very first thing that made sync appear broken — check the Console for that exact warning first if movement isn't syncing in Part 8.
One more piece you'll wire (already written for you): the 4 capsules are pre-placed, not spawned per-player, so none of them know which network player they belong to until login finishes. NetCrafterSyncBinding.Configure(id, hasAuthority) is the one call that tells a capsule "you're player N, and it is/isn't you driving this one." CoopDemoController.cs calls it once per capsule right after login — you'll see this in Part 7.8, nothing extra to do.
Discrete events use the mechanism Chat already introduced
Login, pickups, and disconnects (the 5 messages from Part 2) aren't continuous like position — they're one-off announcements, and they go through the exact same event system Chat used: NetCrafterEventDispatcher (the low-level bus every event funnels through) and NetCrafterSceneEventRouter (the Inspector-friendly, no-code wrapper over it for schema events specifically). Both get added to the scene for you automatically in Part 7.8 — see the note there.
Part 7 — Build the scene#
Every step below assumes an empty scene with nothing in it yet. This is a bigger scene than Chat's — an arena, 4 player avatars, 8 pickups, a hazard, and three UI panels — so it's broken into more sub-steps. Take them in order; several later steps assume earlier GameObjects already exist.
7.1 Camera and lighting
- Select Main Camera. Set Transform → Position to
(0, 16, -14)and Rotation to(48, 0, 0)— a fixed overhead-angled view of the whole arena, since there's no follow-cam in this tutorial. - On the Camera component, set Clear Flags to Solid Color and pick a dark background color (e.g. near-black).
- Right-click in the Hierarchy → Light → Directional Light. Set Rotation to roughly
(55, -30, 0)and Intensity to1.1.
7.2 The arena
- Right-click in the Hierarchy → Create Empty — name it
Arena. Everything in this step is a child of it. - Right-click
Arena→ 3D Object → Cube — name itGround. Set Position to(0, -0.5, 0)and Scale to(22, 1, 22). Give it a dark, desaturated material (Create → Material, drag onto the cube) — this is the floor players stand on. - Create four more cubes as walls, all children of
Arena, each with a slightly lighter gray material than the ground:
| Name | Position | Scale |
|---|---|---|
Wall_North | (0, 1, 11) | (22, 2, 1) |
Wall_South | (0, 1, -11) | (22, 2, 1) |
Wall_East | (11, 1, 0) | (1, 2, 22) |
Wall_West | (-11, 1, 0) | (1, 2, 22) |
7.3 Player avatar slots
You're building 4 pre-placed avatar slots, not spawning avatars dynamically — slot N gets assigned to whichever connected player has LocalUserId N (the host is always 0; joiners are 1 through 3 in join order), decided once, right after login, in Part 7.8's ConfigurePlayerSlots(). Every client independently computes the same assignment from the same rule, so no extra message is needed just to agree on who owns which slot.
Why fixed slots instead of spawning avatars per player as they join? It sidesteps an entire category of "which prefab instance corresponds to which network id, and how does a client find the instance for a player that joined before it did" bookkeeping — at 2-4 players, four always-present slots (three of them simply invisible-until-occupied, visually, since nothing distinguishes an "unfilled" slot from a filled one at rest) is simpler to reason about and to build by hand in this tutorial.
- Right-click in the Hierarchy → Create Empty — name it
Players. - Four times, right-click
Players→ 3D Object → Capsule — name themPlayerSlot0throughPlayerSlot3. - Give each a distinct, bright material color (e.g. red, blue, green, yellow) so they're visually distinguishable — this matters for screenshots/GIFs of the finished demo, not just function.
- Position them at the four corners, well clear of the walls:
| Slot | Position |
|---|---|
PlayerSlot0 | (-8, 1, -8) |
PlayerSlot1 | (8, 1, -8) |
PlayerSlot2 | (-8, 1, 8) |
PlayerSlot3 | (8, 1, 8) |
- On each slot, Add Component → Rigidbody. Check Is Kinematic, uncheck Use Gravity — movement here is script-driven position assignment, not physics-driven force, so a kinematic Rigidbody is what lets the capsule still register trigger events (pickups, the hazard) without physics pushing it around on its own.
- On each slot, Add Component → search for Coop Player Controller (
CoopPlayerController.cs, Part 7.7) → add it. - On each slot, Add Component → NetCrafter → Sync Binding. Leave every field at its default for now (you'll assign the rule in Part 7.6 and the id/authority at runtime via
Configure()) — but uncheck the component itself (the checkbox next to "Sync Binding" in the Inspector, not the GameObject's own checkbox) to disable it. It gets re-enabled per-slot after login, in Part 7.8.
7.4 Pickups
- Right-click in the Hierarchy → Create Empty — name it
Pickups. - Eight times, right-click
Pickups→ 3D Object → Sphere — name themPickup0throughPickup7. Scale each to(0.6, 0.6, 0.6)and give them all the same bright yellow/gold material. - Position them at these eight points (a mix of cardinal and diagonal placements so no path through the arena avoids all of them):
| Pickup | Position |
|---|---|
Pickup0 | (-6, 1, 0) |
Pickup1 | (6, 1, 0) |
Pickup2 | (0, 1, -6) |
Pickup3 | (0, 1, 6) |
Pickup4 | (-4, 1, -4) |
Pickup5 | (4, 1, -4) |
Pickup6 | (-4, 1, 4) |
Pickup7 | (4, 1, 4) |
- On each pickup's Sphere Collider, check Is Trigger.
- On each pickup, right-click the pickup itself → Effects → Particle System — name the child
CollectEffect. On the Particle System component: uncheck Looping, set Duration to0.6, Start Lifetime to0.5, Start Speed to3, and set Start Color to a warm yellow-white. This plays once when the pickup is collected, then the pickup (and eventually the particle GameObject itself) is destroyed/hidden — seeCoopPickup.csbelow. - On each pickup, Add Component → search for Coop Pickup (
CoopPickup.cs, Part 7.7) → add it. Set Pickup Id to match the number in its name (Pickup0→0,Pickup1→1, etc. — these ids are whatCollectPickupRequest/PickupCollectedEventcarry over the network, so they must be unique and must match what you wire in Part 7.8'spickupsarray order). Drag its ownCollectEffectchild's Particle System into Collect Effect.
7.5 Hazard
- Right-click in the Hierarchy → 3D Object → Cube — name it
Hazard. Position(0, 1, 0)(arena center), scale(2.5, 2, 2.5). Give it a distinct warning-red material. - On its Box Collider, check Is Trigger.
- Add Component → search for Coop Hazard (
CoopHazard.cs, Part 7.7) → add it.
7.6 Create the shared sync rule
- In the Project window, navigate to
Assets/PivecLabs/Examples/CoopDemo/(create the folder if it doesn't exist yet). - Right-click → Create → PivecLabs → NetCrafterRelay → Sync Rule — name it
PlayerPositionSyncRule. - In the Inspector, set Rule Name to
PlayerPosition, Sync Target toTransform_Position, Sync Mode toOnChange. - Select all four
PlayerSlot0–PlayerSlot3GameObjects at once (Ctrl/Cmd-click each in the Hierarchy). With all four selected, dragPlayerPositionSyncRuleonto the Rule field of the Sync Binding component in the Inspector — multi-object editing applies it to all four at once.
This is the step Part 6 already warned about, worth repeating here at the point you could actually skip it: if PlayerSlot0–3's Sync Binding → Rule fields are left empty, every avatar moves smoothly on its own screen and never anywhere else. There's a real Console warning for this ("No SyncRuleDefinition assigned... Disabling.") — if movement isn't syncing when you get to Part 8, check the Console for it before anything else.
7.7 Add the gameplay scripts
Three scripts ship with the package (alongside CoopGameState.cs from Part 3.3) — you don't write these from scratch, just attach them. All three already got attached to their respective GameObjects in the steps above; this section is for reference on what they do.
Assets/PivecLabs/Examples/CoopDemo/Scripts/CoopPlayerController.cs — already attached in Part 7.3, shown here for reference only:
Show CoopPlayerController.cs
using UnityEngine;
using UnityEngine.InputSystem;
public class CoopPlayerController : MonoBehaviour
{
public float moveSpeed = 5f;
public bool HasAuthority { get; private set; }
public int PlayerId { get; private set; }
private Vector3 _spawnPoint;
private void Awake()
{
_spawnPoint = transform.position;
}
// Called by CoopDemoController once the local player's id is known (after login) --
// slots this avatar to a specific network player id and decides whether THIS client
// drives it (true) or only receives its position via sync (false).
public void Configure(int playerId, bool hasAuthority)
{
PlayerId = playerId;
HasAuthority = hasAuthority;
}
private void Update()
{
if (!HasAuthority) return;
if (Keyboard.current == null) return;
Vector3 move = Vector3.zero;
if (Keyboard.current.wKey.isPressed) move += Vector3.forward;
if (Keyboard.current.sKey.isPressed) move += Vector3.back;
if (Keyboard.current.aKey.isPressed) move += Vector3.left;
if (Keyboard.current.dKey.isPressed) move += Vector3.right;
if (move != Vector3.zero)
transform.position += move.normalized * moveSpeed * Time.deltaTime;
}
// Called by CoopHazard's trigger. Purely local -- no networking needed here at all,
// since the position change rides on this player's own already-continuous
// NetCrafterSyncBinding updates, which every other client is already receiving.
public void ResetToSpawn()
{
transform.position = _spawnPoint;
}
}Per CODING_NOTES.md's new-Input-System convention: Keyboard.current is always null-checked, never legacy UnityEngine.Input.
Assets/PivecLabs/Examples/CoopDemo/Scripts/CoopPickup.cs — already attached in Part 7.4, shown here for reference only:
Show CoopPickup.cs
using UnityEngine;
public class CoopPickup : MonoBehaviour
{
public int pickupId;
public ParticleSystem collectEffect;
private bool _collected;
private void OnTriggerEnter(Collider other)
{
if (_collected) return;
var player = other.GetComponent<CoopPlayerController>();
if (player == null || !player.HasAuthority) return;
// Optimistic local hide for responsiveness -- the authoritative PickupCollected
// broadcast (below, via MarkCollected) is still what makes this permanent and what
// every OTHER client actually relies on. Host-authoritative validation
// (CoopGameState.TryCollectPickup) is the real dedup guard, not this flag.
HideLocally();
CoopDemoController.Instance.RequestCollectPickup(pickupId);
}
private void HideLocally()
{
_collected = true;
if (collectEffect != null)
{
collectEffect.transform.SetParent(null, true);
collectEffect.Play();
Object.Destroy(collectEffect.gameObject, 2f);
}
gameObject.SetActive(false);
}
// Called on every client (including the collector, redundantly) when the
// PickupCollected broadcast for this pickup arrives -- the actual authoritative signal.
public void MarkCollected()
{
if (_collected) return;
HideLocally();
}
}Why check other.GetComponent<CoopPlayerController>().HasAuthority instead of just "any player touched it"? Every avatar — including the other three players' — exists in every client's own copy of the scene, since they're all pre-placed. Without the HasAuthority check, watching a remote player's avatar walk through a pickup on your own screen would make your own client think you collected it.
Assets/PivecLabs/Examples/CoopDemo/Scripts/CoopHazard.cs — already attached in Part 7.5, shown here for reference only:
Show CoopHazard.cs
using UnityEngine;
public class CoopHazard : MonoBehaviour
{
public float rotateSpeed = 60f;
private void Update()
{
transform.Rotate(Vector3.up, rotateSpeed * Time.deltaTime);
}
private void OnTriggerEnter(Collider other)
{
var player = other.GetComponent<CoopPlayerController>();
if (player != null && player.HasAuthority)
player.ResetToSpawn();
}
}7.8 Build the UI and add the relay/event GameObjects
- Create a Canvas (right-click Hierarchy → UI → Canvas), confirming Render Mode is Screen Space - Overlay.
- Login panel: right-click Canvas → UI → Panel — name it
LoginPanel. Add a Text - TextMeshPro child (Title, textCo-op Arena Login), an Input Field - TextMeshPro child (DisplayNameInput, placeholderEnter your name...), and a Button - TextMeshPro child (ContinueButton, labelContinue). - Lobby panel: right-click Canvas → UI → Panel — name it
LobbyPanel. Add a Button - TextMeshPro (HostButton, labelHost Game), an Input Field - TextMeshPro (JoinCodeInput, placeholderJoin code...), a Button - TextMeshPro (JoinButton, labelJoin Game), a Text - TextMeshPro (JoinCodeDisplay, blank text — filled in at runtime), and a Text - TextMeshPro (StatusText, blank text — shows connection errors/retry status; kept separate fromJoinCodeDisplayso success and error text never share one field). UncheckLobbyPanelto disable it — you start on Login. - Gameplay panel: right-click Canvas → UI → Panel — name it
GameplayPanel. Set its Image component's alpha to0(gameplay is the 3D arena behind it, not a dark overlay). Add a Text - TextMeshPro child (ScoreboardText, blank text, anchored top-left) for the live scoreboard. UncheckGameplayPanelto disable it. - Relay/event GameObjects — added automatically, not built by hand: open Scenes → Scene Assistant in the NetCrafter Relay window (make sure
CoopDemois still the selected schema). Confirm Add Event Dispatcher is checked (it is by default), then click Add to Existing Scene. In one click, this adds:RelayConnectionManager— this schema's own generated connection type, found automatically by looking specifically insideCoopDemo's generated output (not a generic name search), so there's no way for it to end up wired to a different schema's type by mistake. Created already disabled — see the warning below for why that matters.HostActionRouter— this schema's generated host-side logic dispatcher.NetCrafterEventDispatcher— since the toggle was checked.NetCrafterSyncManager— added automatically becauseCoopDemohas aSyncRuleDefinition(the one from Part 7.6); a schema with no sync rules at all wouldn't get one.
- Settings → Scene Events → Add Router to Scene — creates a
SceneEventRouterGameObject listingPlayerJoined,PickupCollected, andPlayerLeft, each with an empty On Received slot (wired in step 9, after the controller exists).
Why the RelayConnectionManager GameObject starts disabled. Its Start() calls Connect() unconditionally, and Connect() defaults to hosting a new session when no join code is pending — active from the instant the scene loads, it would start hosting immediately, before Login has even been looked at. Scene Assistant creates it pre-disabled specifically so this can't happen; CoopDemoController.Awake() (below) also forces it inactive defensively on top of that, the same belt-and-suspenders pattern Chat uses. If you ever build this GameObject by hand instead (Add Component → search for the generated RelayConnectionManager), remember to uncheck it yourself — and if you're also building the Chat tutorial in the same project, don't reuse a GameObject/prefab from that scene for this one: Chat's and Coop's generated classes share the exact same short name, distinguishable only by namespace, and Unity's Add Component search shows only the short name. Building via Scene Assistant (step 5 above) sidesteps this entirely, since it looks up each schema's own type specifically rather than searching by name.
- Attach
CoopDemoController.cs(ships with the package, atAssets/PivecLabs/Examples/CoopDemo/Scripts/CoopDemoController.cs): Create Empty at the Hierarchy root — name itCoopDemoController— drag the script onto it. - Wire 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 |
| Gameplay Panel | GameplayPanel |
| Scoreboard Text | ScoreboardText |
| Player Slots (size 4) | PlayerSlot0, PlayerSlot1, PlayerSlot2, PlayerSlot3, in that exact order — this array's index is the network player id each slot maps to |
| Pickups (size 8) | Pickup0 through Pickup7, in matching order — order doesn't have the same strict meaning as Player Slots (each pickup carries its own Pickup Id field), but keeping them in order avoids confusing yourself later |
- Wire the buttons and events:
ContinueButton→ On Click () →CoopDemoController→OnLoginClicked()HostButton→ On Click () →CoopDemoController→OnHostClicked()JoinButton→ On Click () →CoopDemoController→OnJoinClicked()SceneEventRouter'sPlayerJoinedbinding → On Received →CoopDemoController→OnPlayerJoinedReceived()SceneEventRouter'sPickupCollectedbinding → On Received →CoopDemoController→OnPickupCollectedBroadcastReceived()SceneEventRouter'sPlayerLeftbinding → On Received →CoopDemoController→OnPlayerLeftBroadcastReceived()
That's every wiring step. CoopDemoController.cs ships with the package — nothing left to write. Expand below if you'd like to see what it actually does:
Show CoopDemoController.cs
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using TMPro;
using PivecLabs.Generated.Coop;
using PivecLabs.NetCrafterRelay;
public class CoopDemoController : MonoBehaviour
{
public static CoopDemoController 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 scoreboardText;
public CoopPlayerController[] playerSlots;
public CoopPickup[] pickups;
private RelayConnectionManager relay;
private string displayName = "Player";
private readonly Dictionary<int, int> _scores = new();
private void Awake()
{
Instance = this;
CoopGameState.Reset();
relay = relayConnectionManagerObject.GetComponent<RelayConnectionManager>();
relayConnectionManagerObject.SetActive(false);
ShowLogin();
}
private void Start()
{
NetCrafterEventDispatcher.Instance.Subscribe("RelayHosting", OnRelayHosting);
NetCrafterEventDispatcher.Instance.Subscribe("ConnectionLost", OnConnectionLost);
NetCrafterEventDispatcher.Instance.Subscribe("RelayStatus", OnRelayStatus);
}
private void ShowLogin() { loginPanel.SetActive(true); lobbyPanel.SetActive(false); gameplayPanel.SetActive(false); }
private void ShowLobby() { loginPanel.SetActive(false); lobbyPanel.SetActive(true); gameplayPanel.SetActive(false); }
private void ShowGameplay() { loginPanel.SetActive(false); lobbyPanel.SetActive(false); gameplayPanel.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)
{
joinCodeDisplay.text = "Join code: " + (string)joinCodeObj;
StartCoroutine(SendLoginWhenConnected());
}
private void OnConnectionLost(object reason)
{
Debug.Log("[CoopDemo] Connection lost: " + reason);
// Additive -- a failed host/join attempt never reaches Gameplay, so the player is
// still on the Lobby panel to see this.
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($"[CoopDemo] Failed to connect within {ConnectTimeoutSeconds}s."); yield break; }
if (relay.IsHost)
{
CoopGameState.PlayerNames[relay.LocalUserId] = displayName;
relay.OnPeerDisconnected += OnHostDetectedPeerDisconnected;
yield return new WaitForSeconds(HostLobbyDisplaySeconds);
}
else
{
new LoginRequest { displayName = displayName }.Send(relay);
}
ConfigurePlayerSlots();
RefreshScoreboard();
ShowGameplay();
}
// Assigns each of the 4 pre-placed avatar slots to a network player id and decides
// whether THIS client drives it (the slot matching relay.LocalUserId) or only receives
// its position via NetCrafterSyncBinding. Every client independently computes the same
// assignment -- ids are always 0 (host) through 3 (third joiner) by construction, so no
// broadcast is needed to agree on which slot is whose.
private void ConfigurePlayerSlots()
{
int myId = relay.LocalUserId;
for (int i = 0; i < playerSlots.Length; i++)
{
var slot = playerSlots[i];
if (slot == null) continue;
bool mine = i == myId;
slot.Configure(i, mine);
var binding = slot.GetComponent<NetCrafterSyncBinding>();
if (binding != null)
{
binding.Configure(i, mine);
binding.enabled = true;
}
}
}
// Host-only: fires when RelayConnectionManager detects a joiner has stopped responding.
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
}
// Called by a CoopPickup when the LOCAL player's avatar touches it.
public void RequestCollectPickup(int pickupId)
{
if (relay.IsHost)
{
// Same reasoning as the chat tutorial's OnSendClicked -- Send() has no implicit
// destination on the host, so host-originated actions call the same
// host-authoritative logic (CoopGameState.TryCollectPickup) directly instead of
// routing through a message the host can't send to itself, and broadcast
// directly instead of relying on HandleCollectPickup to run for a message that
// was never actually sent.
if (CoopGameState.TryCollectPickup(pickupId, relay.LocalUserId, out int newScore))
{
var result = new PickupCollectedEvent { pickupId = pickupId, collectorPlayerId = relay.LocalUserId, newScore = newScore };
relay.BroadcastToAll("PickupCollected", result.Serialize(), true);
OnPickupCollectedReceived(pickupId, relay.LocalUserId, newScore);
}
}
else
{
new CollectPickupRequest { pickupId = pickupId }.Send(relay);
}
}
public void OnPlayerJoinedReceived()
{
var ev = (PlayerJoinedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PlayerJoined");
CoopGameState.PlayerNames[ev.playerId] = ev.displayName;
RefreshScoreboard();
}
public void OnPickupCollectedBroadcastReceived()
{
var ev = (PickupCollectedEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PickupCollected");
OnPickupCollectedReceived(ev.pickupId, ev.collectorPlayerId, ev.newScore);
}
private void OnPickupCollectedReceived(int pickupId, int collectorPlayerId, int newScore)
{
foreach (var pickup in pickups)
{
if (pickup != null && pickup.pickupId == pickupId) { pickup.MarkCollected(); break; }
}
_scores[collectorPlayerId] = newScore;
RefreshScoreboard();
}
public void OnPlayerLeftBroadcastReceived()
{
var ev = (PlayerLeftEvent)NetCrafterSceneEventRouter.Instance.GetLastPayload("PlayerLeft");
OnPlayerLeftReceived(ev.playerId);
}
private void OnPlayerLeftReceived(int playerId)
{
CoopGameState.PlayerNames.Remove(playerId);
_scores.Remove(playerId);
RefreshScoreboard();
// The disconnected player's avatar is simply left at its last known position -- no
// more sync updates arrive for it, but nothing removes or hides it. A real game would
// likely grey it out or despawn it; kept simple here on purpose.
}
private void RefreshScoreboard()
{
var sb = new StringBuilder();
foreach (var kv in _scores)
{
string name = CoopGameState.PlayerNames.TryGetValue(kv.Key, out var n) ? n : $"Player{kv.Key}";
sb.AppendLine($"{name}: {kv.Value}");
}
if (scoreboardText != null) scoreboardText.text = sb.ToString();
}
}A finished reference scene with all of the above already wired up lives at Assets/PivecLabs/Examples/CoopDemo/CoopDemoScene.unity.
Notice RequestCollectPickup's relay.IsHost branch, and ConfigurePlayerSlots calling NetCrafterSyncBinding.Configure() — same "host can't Send() to itself" lesson as Chat, now also touching the sync system: the host's own avatar needs its NetCrafterSyncBinding configured with authority just like everyone else's, since TrySendSync branches on IsHost internally (see API Reference) to BroadcastToAll instead of the no-op Send().
Part 8 — Run it#
- Build the scene (File → Build Settings → Build), or use multiple Editor instances if your license supports it.
- Run one copy — this is the host. Enter a name, Continue, Host Game. The join code shows on the Lobby panel for a few seconds before moving on to the arena automatically.
- Run 1–3 more copies — these are joiners (2-4 total players works the same way). Enter a name, Continue, type in the join code, Join Game.
- Move each copy with WASD. Every avatar should move smoothly on its own client and, with a small network delay, on every other client too.
- Walk any avatar onto a pickup. It should disappear immediately on the collector's own screen (optimistic local hide) and shortly after on every other screen too, with the scoreboard updating everywhere to show that player's score incremented by exactly one.
- Walk into the rotating hazard. Your own avatar should snap back to its spawn point — visible to every other client as an ordinary (if abrupt) position update, since it rides the same sync path as normal movement.
- Quit one joiner's copy mid-session (close the window, or kill the process). After a short delay (host-side peer-timeout detection), the remaining copies' scoreboards should drop that player, and everyone else keeps playing normally.
If pickups aren't syncing but avatars are moving, check the Console for HandleCollectPickup logs on the host's copy — same troubleshooting instinct as Chat's HandleSendChat. If avatars aren't syncing at all, see Common Mistakes below before assuming the network itself 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.
Common mistakes#
App hosts a session immediately on Play. Same cause and same fix as Chat: RelayConnectionManager's GameObject (Part 7.8) was left active. Building it via Scene Assistant's Add to Existing Scene (the documented path) creates it already disabled, so this shouldn't come up — but if you built it by hand instead and forgot to uncheck it, or somehow re-enabled it, CoopDemoController.Awake() forces it inactive defensively regardless.
NullReferenceException in SendLoginWhenConnected() the moment you click Host Game (or Join Game). Confirmed live: this means the RelayConnectionManager GameObject's component is the wrong schema's generated class — this was possible when building the GameObject by hand (Add Component's search can't tell two schemas' identically-named classes apart), most commonly by copying/reusing a GameObject or prefab from the Chat tutorial's scene instead of building fresh for Coop. Building it via Scene Assistant's Add to Existing Scene (Part 7.8, step 5) avoids this entirely, since it looks up this schema's own generated type specifically rather than searching by name — if you're seeing this error, check whether the GameObject was built some other way. CoopDemoController.Awake() also logs a specific Console error identifying this exact cause the moment the scene loads ('RelayConnectionManager' has no PivecLabs.Generated.Coop.RelayConnectionManager component attached...) rather than letting it surface only as a stack trace once you click a button. Fix: delete the GameObject and re-add it via Scene Assistant.
Avatars move locally but never on other clients — no errors in the Console. Confirmed live during this tutorial's own testing: check first whether Sync Binding → Rule is actually assigned on all four PlayerSlot GameObjects (Part 7.6) — an unassigned rule self-disables the binding with only a Console warning, not an error, and it's easy to scroll past. If the rule is assigned and it's still not working, confirm NetCrafterSyncManager exists in the scene (Part 7.8) — without it, bindings log a similar self-disabling warning.
A pickup gets collected twice, or the wrong player gets credit. This should be structurally impossible given HandleCollectPickup's CoopGameState.TryCollectPickup guard (Part 3.2) — if you see it anyway, the most likely cause is a Custom Host Code typo that broadcasts unconditionally instead of inside the if, so double-check that edit against Part 3.2 exactly.
A late-joining player's scoreboard shows "Player2" instead of an actual name for someone who joined earlier. This means HandleLogin's roster-replay loop (Part 3.1) is missing or was skipped — without it, PlayerJoined only ever reaches clients that were already connected at that moment, so a late joiner never learns earlier players' names (their scores are still correct either way, since scoring doesn't depend on names).
Score is correct but stays at the generic "PlayerN" fallback for yourself, even on your own client. Check that SendLoginWhenConnected's host branch sets CoopGameState.PlayerNames[relay.LocalUserId] = displayName; for the host specifically — a joiner's own name arrives back to it via the ordinary PlayerJoined broadcast, but the host never sends itself a Login message (same "host can't Send() to itself" pattern as everywhere else), so it has to register its own name directly.
NetCrafter Relay — Pivec Labs