NetCrafter Relay — Racing Tutorial
Build a host-relayed multiplayer racing game with real-time car sync and a host-authoritative lap system.
By Pivec Labs
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); ARCADE: FREE Racing Car imported from the Unity Asset Store (search "ARCADE Free Racing Car" by Mena). 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 oval track where every connected player drives a physics-based car — position and rotation replicated live to every other client — with a host-authoritative lap counter and a live leaderboard. This is the most advanced of NetCrafter Relay's tutorials: it's the only one built around time-based, host-validated scoring rather than one-shot events (Chat's messages) or simple collision pickups (Co-op).
Part 0 — What's different about this tutorial#
Racing reuses everything Chat/Co-op/Character already established — Login → Lobby (real join code, Host/Join) → Gameplay, RelayConnectionManager, sync rules for continuous transform replication, NetCrafterEventDispatcher for framework events, Scene Events for schema events — without changing any of it. Two things are genuinely new:
- A lap system that's host-authoritative for a reason that isn't obvious upfront. A player's own client detects when their own car crosses the finish line (a trigger collider, purely local), but it doesn't just display "Lap 2" — it requests the host record the lap. The host is the only place a lap actually gets counted (
RacingGameState.TryRecordLap), the same "one authority decides, everyone reacts to the broadcast" shape as Co-op's pickups, just guarding against an implausibly-fast repeat instead of an exact duplicate — see Part 4. - Two sync rules on one car, not six driving one character (compare Character's tutorial):
Transform_PositionandTransform_Rotationmove the car — that's all. There's noRigidbody_Velocity/CarVelocitysync rule; the remote wheel-spin visual is approximated entirely from position delta between frames instead (Part 1.5), which sidesteps a kinematic remote Rigidbody's own velocity never reflecting transform-driven movement in the first place. Covered in full in Part 7.
Part 1 — Single-player setup#
This part has nothing to do with networking yet — it gets a drivable car on a track, entirely locally. Skip ahead to Part 2 if you already have one.
1.1 Import the track
The track is a ready-made environment package, not something you build by hand in this tutorial.
- Get Cartoon Race Track - Oval by RCC Design from the Unity Asset Store. Confirmed at time of writing: this is a free asset (Standard Unity Asset Store EULA, Extension Asset license) — add it to your account from the web store page, then in Unity's Package Manager, switch the source dropdown to My Assets, find it in the list, and click Import.
- Drag the track prefab into your scene from wherever the package places it in the Project window (the exact folder name depends on the package version — look for an
RCCorCartoon Race Trackfolder). Position it at world origin(0, 0, 0).
Check for magenta/pink materials immediately after import — this is the single most common point of confusion with any imported environment package, not just this one. The Asset Store listing's own compatibility table lists this package as supporting Built-in, URP, and HDRP — but that table reflects what the publisher has tagged, not a guarantee every material's shader was actually authored for whichever pipeline your project uses. This project uses URP (confirmed in Edit → Project Settings → Graphics — a Universal Render Pipeline asset is assigned). If any part of the track renders solid magenta/pink after import, that's Unity's unmistakable "missing shader" signal, and the fix is one step: Window → Rendering → Render Pipeline Converter (Unity 6) — or Edit → Render Pipeline → Universal Render Pipeline → Upgrade Project Materials on older Unity versions — run the Built-in-to-URP material upgrade, pointed at the track's own material folder. Do this before moving on to placing the car or checkpoints; a magenta track makes it much harder to see where anything actually is.
3. Look at the track's own hierarchy before continuing to Part 1.4 — you'll need to know where its start/finish line actually is to place the local car and (in Part 7.3) the lap checkpoint trigger. Package contents vary by version; if the track ships its own spawn markers or a finish-line object, use those positions directly. If it doesn't, use the track's own visible start/finish line marker (every oval track package has one) as your reference point instead of a hardcoded coordinate — this tutorial can't give you an exact number for a package it doesn't ship.
1.2 Import the car
This tutorial's CarController.cs (Part 1.5) and CarCamera.cs (Part 1.6) — and CarColorManager.cs, attached later in Part 7 — are all built against one specific free car package. Nothing else in this doc tells you where to get it, so do this now, before Part 1.4 expects it to already be in the project.
- Get ARCADE: FREE Racing Car by Mena from the Unity Asset Store. Confirmed at time of writing: this is a free asset (Standard Unity Asset Store EULA, Extension Asset license) — add it to your account from the web store page, then in Unity's Package Manager, switch the source dropdown to My Assets, find it in the list, and click Import.
- In the Project window, find the package's main car prefab (exact folder name/prefab name may vary by version — look for a folder from the "ARCADE" or "Racing Car" package; there's normally just one obvious car prefab, not several to choose between). You'll drag this into your scene in Part 1.4 — nothing to do with it yet.
Same render-pipeline caveat as the track (Part 1.1): this package's own Asset Store listing shows Built-in and URP as compatible, HDRP as not — if you're on HDRP for some reason, this asset isn't the one to use. On URP (this project's pipeline), check for magenta/pink materials after import the same way you did for the track, and run the Render Pipeline Converter if you see any.
1.3 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.4 Place the car
- Drag the ARCADE: FREE Racing Car prefab (Part 1.2) into the scene, at your track's start line. Right-click it → Prefab → Unpack Completely — see the warning in Part 7.2 for exactly why this matters before this tutorial is done with it; doing it now, before you've touched a single component, costs nothing and avoids the trap entirely.
- Rename the root GameObject
LocalCar. - Confirm the car's hierarchy has separate WheelCollider components (not just visual wheel meshes) — CarController (Part 1.5) expects to find
WheelColliders to drive. If the imported prefab only has visual wheel meshes with no colliders, add fourWheelCollidercomponents as children of the car root, roughly at each wheel's position, matching the visual mesh radius.
1.5 Car Controller
Assets/PivecLabs/Examples/RacingDemo/Scripts/CarController.cs ships with the package — nothing to create here, this is what it contains:
Show CarController.cs
using UnityEngine;
using UnityEngine.InputSystem;
public class CarController : MonoBehaviour
{
[Header("Wheel Colliders")]
public WheelCollider frontLeft;
public WheelCollider frontRight;
public WheelCollider rearLeft;
public WheelCollider rearRight;
[Header("Wheel Visuals (optional -- spin/steer animation only, no physics)")]
public Transform frontLeftVisual;
public Transform frontRightVisual;
public Transform rearLeftVisual;
public Transform rearRightVisual;
[Header("Handling")]
public float motorTorque = 1500f;
public float maxSteerAngle = 30f;
public float brakeTorque = 3000f;
public bool HasAuthority { get; private set; }
public int PlayerId { get; private set; }
// ...full source in the shipped file -- Configure(), DriveLocal(), the remote-car
// wheel-spin approximation, and ResetToSpawn() are covered below and in Part 7.
}Input System — no Input Actions asset needed. CarController reads keyboard input directly via Keyboard.current from Unity's new Input System, guarded for null per this package's own coding conventions. No Input Actions asset or Player Input component is required. If you see an error about the Input class switching, confirm Active Input Handling is set to Input System Package or Both (see Prerequisites).
Setup steps:
- Select
LocalCar's root GameObject. - Add Component → search for Car Controller → add it.
- Drag the four
WheelCollidercomponents into Front Left / Front Right / Rear Left / Rear Right. - If the car has separate visual wheel mesh Transforms (distinct from the WheelColliders themselves), drag those into the matching Wheel Visual fields — optional, only used to spin/steer the wheels visually; leave blank and the car still drives correctly, it just won't show wheel rotation.
CarController.Configure(playerId, hasAuthority) isn't called yet — that happens once login is wired up in Part 7.6. For now, in Play mode with no RacingDemoController in the scene at all, HasAuthority defaults to false and the car won't respond to input; that's expected at this stage, not a bug. If you want to test raw driving feel right now before wiring up anything else, temporarily call GetComponent<CarController>().Configure(0, true) from any script's Start(), test, then remove it — Part 7 replaces this properly.
1.6 Follow Camera
Assets/PivecLabs/Examples/RacingDemo/Scripts/CarCamera.cs ships with the package — nothing to create here, this is what it contains:
Show CarCamera.cs
using UnityEngine;
public class CarCamera : MonoBehaviour
{
public Transform target;
public Vector3 offset = new(0f, 4f, -9f);
public float smoothTime = 0.18f;
// ...full source in the shipped file. SmoothDamp for position, Slerp for
// rotation, so it feels weighty without being jittery.
}- Select your Main Camera.
- Add Component → search for Car Camera → add it.
- Target: assign
LocalCar's Transform (for now — Part 7.6 reassigns this automatically once a real local car is spawned after login). - Leave Offset and Smooth Time at their defaults, or adjust to taste.
1.7 Test single-player driving
Temporarily call Configure(0, true) as described in 1.5, press Play, and confirm:
- W / Up arrow: accelerate
- S / Down arrow: brake/reverse
- A/D or Left/Right arrows: steer
- Space: handbrake
Adjust motorTorque (default 1500) and maxSteerAngle (default 30) on CarController if the handling feels too aggressive or too sluggish. Remove the temporary Configure() call before continuing — Part 7 wires this up for real.
Everything above this line is completely relay-agnostic — if you already have a working single-player driving setup, there was nothing relay-specific to change here. The interesting part starts now.
Part 2 — Create the schema#
Tools → Pivec Labs → NetCrafter Relay. Click + Create Definition, enter Racing, click Create.
Part 3 — Define the messages#
Open the Gameplay Messages tab. Create the following.
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 four fields: playerId (int), displayName (string), colorIndex (int), spawnIndex (int). colorIndex picks this player's car colour (Part 3's CarColorManager, 8 colours); spawnIndex picks which spawn point their car starts at — both computed host-side in Part 4, not sent by the joining client.
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 (int).
Only covers a joiner disconnecting — the host detects it and broadcasts this message to everyone else (Part 7.6). If the host itself disconnects, there's no one left to broadcast anything: each remaining joiner detects that independently, locally, without a PlayerLeft message at all — see the warning in Part 7.6.
3.4 RequestLapCompleted — a client's own car crossed the finish line
| Field | Value |
|---|---|
| Message Name | RequestLapCompleted |
| Direction | ClientToHost |
| Transport | Reliable |
| Host Handler Name | HandleLapCompleted |
No fields — the host already knows who sent it (see Part 4).
3.5 LapCompleted — host's authoritative lap result, broadcast to everyone
| Field | Value |
|---|---|
| Message Name | LapCompleted |
| Direction | HostToClient |
| Transport | Reliable |
| Host Handler Name | (leave blank) |
Add four fields: playerId (int), lapNumber (int), lapTimeSeconds (float), totalTimeSeconds (float).
Same lesson as Chat/Co-op/Character: there's no message here for a car's continuous position or rotation. Those replicate through the sync system (Part 6), not the schema/message system — the five messages above only ever fire once each per event (login, join, leave, lap-request, lap-result), never on a per-frame cadence.
Part 4 — Define the Host Actions#
Open the Host Actions tab. Create two.
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 Co-op/Character's HandleLogin — see either tutorial for the full explanation of why the replay loop exists — plus Racing-specific colour/spawn assignment):
string displayName = trigger.displayName;
int colorIndex = senderId % 8;
int spawnIndex = senderId % 4;
foreach (var kv in RacingGameState.Players)
{
var existing = new PlayerJoinedEvent {
playerId = kv.Key, displayName = kv.Value.displayName,
colorIndex = kv.Value.colorIndex, spawnIndex = kv.Value.spawnIndex
};
connection.SendToPeer(senderId, "PlayerJoined", existing.Serialize(), true);
}
RacingGameState.Players[senderId] = new RacingGameState.PlayerInfo {
displayName = displayName, colorIndex = colorIndex, spawnIndex = spawnIndex
};
var joined = new PlayerJoinedEvent {
playerId = senderId, displayName = displayName, colorIndex = colorIndex, spawnIndex = spawnIndex
};
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.
RacingDemoController.Instance?.HostRegisterPlayerLocally(senderId, displayName, colorIndex, spawnIndex);colorIndex/spawnIndex are computed here, host-side, from senderId — deterministic and never trusted from the client. This mirrors the same "always use the authoritative sender id, never trust a client-sent one" rule this package has followed since its very first (SFS3-era) handler generation — the reasoning hasn't changed even though nothing about how it's enforced is SFS3-specific anymore.
4.2 HandleLapCompleted
| Field | Value |
|---|---|
| Handler Name | HandleLapCompleted |
| Trigger Message | RequestLapCompleted |
| Response Event | LapCompleted |
| Logic Type | Custom |
Custom Host Code:
if (RacingGameState.TryRecordLap(senderId, out int lapNumber, out float lapTimeSeconds, out float totalTimeSeconds))
{
var ev = new LapCompletedEvent {
playerId = senderId, lapNumber = lapNumber,
lapTimeSeconds = lapTimeSeconds, totalTimeSeconds = totalTimeSeconds
};
connection.BroadcastToAll("LapCompleted", ev.Serialize(), true);
// Same reasoning as HandleLogin above -- the host never receives its own broadcast.
RacingDemoController.Instance?.HostRegisterLapLocally(senderId, lapNumber, lapTimeSeconds, totalTimeSeconds);
}Why this is host-authoritative, and what it actually validates. The host doesn't simulate every car's physics itself — this is a relay, not a physics-authoritative server, so it has no way to verify a lap was actually driven honestly. What it can do, and what RacingGameState.TryRecordLap does, is refuse to count a lap that arrives implausibly soon after that same player's previous one (a MinLapSeconds floor, 5f by default) — not a perfect anti-cheat, but it does mean the single source of truth for "how many laps has player X completed" is the host's own count, broadcast to everyone, rather than each client trusting its own (or worse, another client's) locally-detected checkpoint crossing. Every client, including the one whose car actually crossed the line, updates its lap display from the LapCompleted broadcast — never directly from its own LapCheckpoint trigger. See Part 7.3.
Part 5 — Configure Relay Settings and generate#
Open Settings → Relay Settings and fill in your own coturn/rendezvous details (identical fields to every other tutorial — 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/Racing/Generated/:
Client/LoginRequest.cs,Client/PlayerJoinedEvent.cs,Client/PlayerLeftEvent.cs,Client/RequestLapCompletedRequest.cs,Client/LapCompletedEvent.cs,Client/RelayConnectionManager.csHost/HostActionRouter.csConfig/RelayConfig.json
Let Unity recompile and check the Console for CS0103: The name 'RacingGameState' does not exist or CS0103: The name 'RacingDemoController' does not exist — if you see either, double-check Part 6.1's file exists (below).
6.1 The shared game-state helper
Assets/PivecLabs/Examples/RacingDemo/Scripts/RacingGameState.cs ships with the package — nothing to create here, this is what it contains:
Show RacingGameState.cs
using System.Collections.Generic;
using UnityEngine;
public static class RacingGameState
{
public class PlayerInfo
{
public string displayName;
public int colorIndex;
public int spawnIndex;
}
public static readonly Dictionary<int, PlayerInfo> Players = new();
private const float MinLapSeconds = 5f;
public static void Reset() { /* ...clears all tracked state, called from Awake() */ }
public static bool TryRecordLap(int playerId, out int lapNumber, out float lapTimeSeconds, out float totalTimeSeconds)
{
// ...full source in the shipped file -- see Part 4.2's warning for what this
// actually validates and why.
}
}Keep this file outside Assets/PivecLabs/NetCrafterRelay/ — same assembly-isolation reasoning as ChatPlayerRegistry/CoopGameState/CharacterGameState. If you're building multiple tutorials in the same project, RacingGameState doesn't collide with any of them — all four are distinctly named plain static classes compiling into the same Assembly-CSharp assembly.
Part 6 — Create the two sync rules#
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 — this is the name the runtime actually uses to key sync data, and it deliberately doesn't have to match the filename.
Every rule below is a SyncRuleDefinition asset, created in Assets/PivecLabs/Examples/RacingDemo/ via right-click → Create → PivecLabs → NetCrafterRelay → Sync Rule.
| Asset name | Rule Name | Sync Target | Sync Mode |
|---|---|---|---|
CarPositionSyncRule | CarPosition | Transform_Position | OnChange |
CarRotationSyncRule | CarRotation | Transform_Rotation | OnChange |
Create and rename one row at a time — don't create both, 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. Skip this and Unity silently appends " 1", " 2" to keep filenames unique (SyncRule 1.asset) — the risk is smaller with only two rules than Character's six, but the mechanism (and the fix, if you already have one: select it, press F2, retype the correct name) is identical.
These two rules directly move a remote car — Transform_Position/Transform_Rotation are applied immediately, with no interpolation (see the note on NetCrafterSyncBinding applying incoming values immediately in the Common Mistakes section of the Character tutorial — the same behavior applies here). Nothing else needs to be synced: the remote wheel-spin visual (Part 1.5) is entirely a local approximation from position delta between frames, not driven by any additional sync rule.
You'll assign these two rules to two NetCrafterSyncBinding components in Part 7 — one set on the local car, another matching set on the remote car prefab.
Part 7 — Build the scene#
7.1 Add sync bindings to the local car
Select LocalCar's root GameObject (from Part 1.4) in your scene. Twice: Add Component → NetCrafter → Sync Binding, then immediately drag one of Part 6's two rule assets onto that specific binding's Rule field before adding the next one.
Leave every added Sync Binding component's checkbox unchecked — RacingDemoController (Part 7.6) enables all of them together, with the correct objectId/authority, right after login.
Leave Has Authority unchecked and Object Id at 0 on both of these components — don't set them here, for either the local car or (in Part 7.2) the remote prefab. These are visible, editable Inspector fields, but they're only ever meant to be set by Configure(id, authority) at runtime, once login actually confirms this client's real player id and whether it's the host. Setting them by hand risks a one-time setup step (OnEnable() — subscribing to incoming sync, among other things) locking in against the wrong value before Configure() ever runs, since Configure() updates the fields but doesn't re-run that setup — see the Character tutorial's Part 7.1 for the full mechanism if you want the details. Leaving the checkbox unchecked (as instructed above) is what prevents this from ever mattering.
7.2 Build the remote car prefab
Step 1 — Duplicate, unpack, and check. Right-click LocalCar in the Hierarchy → Duplicate. Rename the duplicate RemoteCarPrefab. If you didn't already unpack the original car prefab back in Part 1.4, right-click RemoteCarPrefab → Prefab → Unpack Completely now, before touching any components.
Don't skip the unpack — this is a real, confirmed trap, not a defensive formality (see the Character tutorial's Part 7.2 for the full mechanism: a duplicated prefab instance can still be linked to the same source as the original, and editing one can silently affect the other, or saving the duplicate can produce a Prefab Variant instead of an independent prefab). Unpacking severs that link immediately, before it can happen.
Both NetCrafterSyncBinding components already exist on RemoteCarPrefab — they were duplicated along with everything else in Step 1, rules and all. Leave both of them unchecked, same as the local car — RacingDemoController configures and enables both the moment this prefab is spawned for an actual remote player (Part 7.6).
Step 2 — Disable local-only components. CarController stays on RemoteCarPrefab (it drives BOTH local and remote behavior internally, gated on HasAuthority — see Part 1.5's source), but nothing else needs to change here: Configure(playerId, false) (called at spawn time, Part 7.6) is what actually disables the Rigidbody's physics and the WheelColliders for a remote instance, not a manual component removal step. There's nothing else on the car that reads local input.
Why there's no Rigidbody_Velocity sync rule here. Configure(playerId, false) sets the remote car's Rigidbody isKinematic = true — correct and necessary, so its own local physics can't fight the direct position/rotation writes Transform_Position/Transform_Rotation sync apply every update (same reasoning as the root-motion note in the Character tutorial). A kinematic Rigidbody's own .linearVelocity never reflects movement driven by directly assigning transform.position the way this sync does, so a velocity-based sync rule has nothing meaningful to read on a remote car regardless. The remote wheel-spin visual (Part 1.5) is driven entirely by approximating forward speed from the position delta between frames instead — no Rigidbody read involved, and no third sync rule needed to support it. (An earlier revision of this tutorial did ship a CarVelocity/Rigidbody_Velocity rule anyway, purely as an unused artifact of an earlier wheel-spin approach — removed once confirmed nothing consumed it; see the Changelog if you're curious.)
Step 3 — Save and remove from the Hierarchy. Drag RemoteCarPrefab from the Hierarchy into Assets/PivecLabs/Examples/RacingDemo/Prefabs/RemoteCarPrefab.prefab, then delete it from the Hierarchy — it's now a prefab asset, spawned at runtime instead of pre-placed.
7.3 Place the lap checkpoint
Assets/PivecLabs/Examples/RacingDemo/Scripts/LapCheckpoint.cs ships with the package — nothing to create here, this is what it contains:
Show LapCheckpoint.cs
using UnityEngine;
[RequireComponent(typeof(Collider))]
public class LapCheckpoint : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
var car = other.GetComponentInParent<CarController>();
if (car == null || !car.HasAuthority) return;
RacingDemoController.Instance?.RequestLapCompleted();
}
}- At your track's start/finish line (the exact spot you noted in Part 1.1), Create Empty — name it
LapCheckpoint. - Add Component → Box Collider. Size and position it to span the full width of the track at the finish line, thin along the direction of travel (a car should pass through it in one frame at racing speed, not sit inside it). Check Is Trigger.
- Add Component → search for Lap Checkpoint → add it.
If your track package didn't ship an obvious finish-line marker to align this against, the start/finish line is wherever you decide it is — place LapCheckpoint anywhere on the track's own road surface, spanning its width, and treat that as the finish line for spawn-point placement (Part 7.6) too, so cars start facing the direction they'll complete a lap in.
7.4 Build the UI
- Canvas (Screen Space - Overlay) — name it
UICanvas. - LoginPanel: TMP Text (
Title, "Racing"), 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 track behind it): TMP Text (LeaderboardText, blank, anchored top-left) — sorted lap count / time, refreshed on everyLapCompletedand player join/leave.
7.5 Create the RelayConnectionManager prefab and add the relay/event GameObjects
Open Scenes → Prefab Generator in the NetCrafter Relay window, confirm Racing is the selected schema, click Create RelayConnectionManager Prefab. This writes RelayConnectionManager.prefab to Definitions/Racing/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 Racing 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 (Racing's generated connection type), added already disabled.HostActionRouter—Racing's generated host-side logic dispatcher.NetCrafterEventDispatcher.NetCrafterSyncManager— added automatically becauseRacinghasSyncRuleDefinitionassets (Part 6).
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; RacingDemoController.Awake() (below) also forces it inactive defensively on top of that, the same belt-and-suspenders pattern every other tutorial in this package uses.
Then: Settings → Scene Events → Add Router to Scene — creates a SceneEventRouter GameObject listing PlayerJoined, PlayerLeft, and LapCompleted, each with an empty On Received slot (wired in 7.6).
7.6 Attach and wire RacingDemoController
Assets/PivecLabs/Examples/RacingDemo/Scripts/RacingDemoController.cs ships with the package — nothing to create here, this is what it contains:
Show RacingDemoController.cs
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using TMPro;
using PivecLabs.Generated.Racing;
using PivecLabs.NetCrafterRelay;
public class RacingDemoController : MonoBehaviour
{
public static RacingDemoController 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;
public GameObject gameplayPanel;
public TMP_Text leaderboardText;
public GameObject localCar;
public GameObject remoteCarPrefab;
public Transform[] spawnPoints;
public CarCamera carCamera;
// ...full source in the shipped file. See Part 4 for HostRegisterPlayerLocally /
// HostRegisterLapLocally (called directly from Custom Host Code, same "host never
// receives its own broadcast" reasoning used throughout this package), and Part 7.3
// for RequestLapCompleted (called by LapCheckpoint for the local player's own car).
}Wire it up:
- Create an empty GameObject named
RacingDemoController, drag the script onto it. - 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 |
| Leaderboard Text | LeaderboardText |
| Local Car | LocalCar (Part 1.4) |
| Remote Car Prefab | RemoteCarPrefab.prefab (Part 7.2) |
| Spawn Points (size 4) | four empty GameObjects placed around your track's start line, near LapCheckpoint (Part 7.3), each facing the direction of travel — this array's index is the network player id % 4 each spawn point maps to (Part 4.1) |
| Car Camera | your Main Camera's CarCamera component (Part 1.6) |
- Wire the buttons and events:
ContinueButton→ On Click () →RacingDemoController.OnLoginClicked()HostButton→ On Click () →RacingDemoController.OnHostClicked()JoinButton→ On Click () →RacingDemoController.OnJoinClicked()SceneEventRouter'sPlayerJoinedbinding → On Received →RacingDemoController.OnPlayerJoinedReceived()SceneEventRouter'sPlayerLeftbinding → On Received →RacingDemoController.OnPlayerLeftBroadcastReceived()SceneEventRouter'sLapCompletedbinding → On Received →RacingDemoController.OnLapCompletedReceived()
Confirmed live: a departed player's car used to stay behind forever if the departing player was the HOST, not a joiner. Disconnect detection itself (RelayConnectionManager's OnPeerDisconnected, ~15s of silence — the same for a graceful quit and an ungraceful one, since neither sends any explicit "I'm leaving" message) already worked correctly either way. The gap was in who listens: SendLoginWhenConnected's host branch always subscribed to OnPeerDisconnected so the host could broadcast PlayerLeft and clean up a departed joiner's car everywhere — but nothing on the joiner side ever subscribed, so if the host disconnected, every remaining joiner just kept the host's now-frozen car sitting in their scene indefinitely, with no message coming to tell them otherwise. RacingDemoController.cs now also subscribes in the joiner branch: since a joiner's only peer is always the host, and the host is always assigned network id 0, OnPeerDisconnected firing there reuses the exact same OnPlayerLeftReceived(0) cleanup a PlayerLeft broadcast would have triggered — destroying the host's car and dropping it from the leaderboard — plus a StatusText message, since unlike an ordinary player leaving, this is the whole session ending for that client, not just one fewer racer. There's still no host migration or reconnection: once the host is gone, remaining joiners have no path back to the Lobby panel without restarting — a known, disclosed limitation, not something this fix attempts to solve.
Part 8 — Run it#
- Run one copy — the host. Enter a name, Continue, Host Game. The Lobby panel stays up with your join code for a few seconds before moving on to the track automatically. Drive a lap — confirm your own car handles exactly as in Part 1.7, and the leaderboard shows your name at "Lap 0" until you cross
LapCheckpoint, then "Lap 1" with a time. - Run 1–3 more copies — joiners. Enter a name, Continue, type in the join code, Join Game.
- On every client, confirm:
- Every other connected player's car appears, correctly coloured (
colorIndex), at its own spawn point. - Driving on any one client's own car shows smooth movement — not just a moved position, actual wheel spin — on every other client's screen.
- Crossing
LapCheckpointupdates the leaderboard identically on every client, including the one that crossed it, at the same lap number and time. - The leaderboard re-sorts as lap counts/times change.
- Every other connected player's car appears, correctly coloured (
- Quit one joiner. After a short delay (host-side peer-timeout detection), that car disappears from every remaining client and the leaderboard drops them.
- With at least one joiner still connected, quit the host instead. After the same short delay, each remaining joiner's
StatusTextshows a "Host disconnected" message and the host's own now-frozen car disappears from their scene — independently on each joiner's client, since joiners never talk to each other directly (Part 7.6). There's no way to keep playing after this — no host migration — so this is as far as that session goes; host or join a fresh session to continue testing.
If hosting or joining fails outright — relay unreachable, join code not found — StatusText on the Lobby panel 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.
If cars move but the leaderboard never updates, check the Console for HandleLapCompleted logs on the host's copy — same troubleshooting instinct as every other tutorial's host-action debugging. If cars don't appear at all for a joiner, see Common Mistakes below before assuming the network itself is broken.
Common mistakes#
A remote car doesn't move at all. Check that both NetCrafterSyncBinding → Rule fields on RemoteCarPrefab are actually assigned (Part 7.1/7.2) — an unassigned rule self-disables with a Console warning ("No SyncRuleDefinition assigned... Disabling."), the same failure mode documented in the Co-op and Character tutorials.
Remote wheels don't spin, but the car itself moves correctly. Wheel spin is approximated entirely from the remote car's own position delta between frames (Part 1.5) — there's no sync rule involved in wheel-spin at all, so this isn't a sync-binding wiring issue. Check that Wheel Visual fields are actually assigned on RemoteCarPrefab's CarController (Part 1.5) — if those are all left blank, there's nothing for the spin animation to apply to, and that reads identically to "wheels not spinning."
Track renders solid magenta/pink. Missing shader — the imported track's materials weren't converted for this project's render pipeline (URP). Run Window → Rendering → Render Pipeline Converter (or Edit → Render Pipeline → Universal Render Pipeline → Upgrade Project Materials on older Unity versions) — see Part 1.1.
Lap never counts even though the car clearly crossed the line. Two likely causes: (1) LapCheckpoint's Box Collider isn't actually set to Is Trigger, so OnTriggerEnter never fires at all; (2) you're testing laps faster than RacingGameState.MinLapSeconds (5f by default) apart — the host-authoritative guard in Part 4.2 is rejecting it as implausibly fast, not silently failing. Slow down, or lower MinLapSeconds in RacingGameState.cs for testing.
Joiners see each other's cars fine, but the host sees no one. Confirmed pattern from every other tutorial in this package — this is exactly what happens without HandleLogin's HostRegisterPlayerLocally call (Part 4.1). BroadcastToAll never loops back to the host's own incoming-message pipeline, so the host's OnPlayerJoinedReceived() never fires for anyone — the host has to be told about its own joiners directly. If you're seeing this, double-check RacingDemoController.Instance exists and HandleLogin's Custom Host Code ends with the HostRegisterPlayerLocally line.
NullReferenceException clicking Host Game or Join Game. Same cause as every other tutorial: the RelayConnectionManager GameObject's component is a different schema's generated class, most likely from copying a GameObject/prefab out of another tutorial's scene instead of building this one via Scene Assistant. Delete it and re-add via Scenes → Scene Assistant → Add to Existing Scene.
What's Next#
| Feature | Description |
|---|---|
| Race start/countdown | Gate LapCheckpoint (or car input) behind a host-broadcast "go" signal so laps can't start before everyone's ready |
| Best-lap tracking | RacingGameState already computes lapTimeSeconds per lap — track a per-player minimum and surface it on the leaderboard |
| Position order | Compare every player's (lapNumber, totalTimeSeconds) — already computed — to show live 1st/2nd/3rd, not just a lap count |
| AI opponents | A non-networked car following a waypoint path, using the same CarController but never calling Configure() with real network authority |
| Power-ups | A pickup trigger following Co-op's TryCollectPickup host-authoritative pattern directly |
Tutorial by Pivec Labs — NetCrafter Relay