Multiplayer Racing with NetCrafter
Build a complete real-time racing game — a physics car on a procedural track, with up to 16 players connected through SmartFoxServer 3 and lerp-smoothed remote cars.
What it is: Installed and working — you completed the Chat tutorial.
Namespace for this project: Racing
What it is: A free Unity Asset Store vehicle model with proper wheel hierarchy.
How to get it
- Search "ARCADE Free Racing Car" in the Asset Store (by Mena)
- Import into your project
- Go to Edit → Rendering → Materials → Convert Selected Built-in Materials to URP to fix pink materials
What it is: Running locally — same setup as the Chat tutorial.
New zone: RacingZone on port 9977
What it is: Same build tools used in the Chat tutorial.
Already detected by NetCrafter if the Chat tutorial is working.
Prerequisites¶
This tutorial assumes you have completed the NetCrafter Chat Tutorial. You should be familiar with:
- Creating a schema and adding messages in the NetCrafter window
- Running Generate Client Code and Generate Server Code
- Building and deploying a JAR to SmartFoxServer 3
- Deploying a zone config and restarting SFS3
The Racing schema is completely independent of the ChatRoom schema. All generated code goes into the Racing namespace and a separate RacingZone.
About the Examples folder
Assets/PivecLabs/Examples/Racing/ ships with a prebuilt reference scene (RacingDemoScene.unity) for comparison once you're done — it is not a shortcut. Build your own scene from scratch by following the steps below.
Placeholder note: the example's car is a plain tagged cube standing in for a real vehicle — it has no WheelColliders or steering visuals. Swap in the ARCADE Free Racing Car asset (or your own vehicle) for anything beyond a networking demo.
Project Setup¶
Create the script folder structure. All six Racing scripts live in Assets/PivecLabs/Examples/Racing/Scripts/ — inside the NetCrafter package for export, but outside the NetCrafter/ subfolder so they compile into Assembly-CSharp alongside the generated code.
Assets/PivecLabs/Examples/Racing/Scripts/ TrackBuilder.cs ← procedural track builder CarController.cs ← physics car + remote lerp CarCamera.cs ← smooth follow camera CarColorManager.cs ← 8-colour player system RacingNetworkManager.cs ← all multiplayer logic RacingUIManager.cs ← login / HUD panels
All six scripts are provided in this tutorial. Copy them into the folder and Unity will compile them automatically.
TextMeshPro: TextMeshPro is built into Unity 6. Before using any TMP components, import TMP Essentials once: Window → TextMeshPro → Import TMP Essentials. Click Import when prompted.
First — Enable the Racing Example Scripts
Before attaching any scripts or creating any GameObjects, add the scripting define symbol that enables the racing example scripts:
- Go to Edit → Project Settings → Player
- Find Scripting Define Symbols (under Other Settings)
- Add:
NETCRAFTER_RACING_EXAMPLE - Click Apply
This must be done BEFORE proceeding with any other steps in this tutorial.
Assembly Isolation
Generated client files compile into Assembly-CSharp (no .asmdef). Your game scripts must be in the same assembly to reference generated types like PlayerJoinedEvent. Placing scripts in Assets/PivecLabs/Examples/Racing/Scripts/ (no .asmdef ancestor) satisfies this requirement.
Create a Simple Track¶
TrackBuilder.cs generates a rectangular oval track from Unity primitives — no external assets needed. Adjust parameters in the Inspector, then click ⋮ → Rebuild Track to regenerate.
Track layout
The track is a closed rectangular loop made from overlapping cube primitives:
| Piece | Description |
|---|---|
| Ground | Large green plane (~3× track size) |
| North + South straights | Long dark-grey road cubes, east–west |
| East + West connectors | Short grey cubes on each side, running between the two corner zones |
| Curved corners | Arc of cornerSegments small rotated cubes per corner (default 8), creating smooth 90° turns |
| Outer barriers | White wall cubes along each road's outer edge |
| Inner barriers | White wall cubes along each road's inner edge |
| Start/finish line | White flat marker at the north straight |
| Spawn points | Empty GameObjects on the north straight, evenly spaced |
Adding the track to your scene
- In the Hierarchy, right-click the scene name (at the very top of the Hierarchy panel, e.g.
RacingScene) → Create Empty. Name itTrack.
Important: right-click the scene name, not an existing GameObject. This ensures Track is created at world position 0,0,0. - With
Trackselected, check the Transform component — confirm Position is0, 0, 0. If not, click ⋮ on the Transform component → Reset. - Add Component → search for
TrackBuilder→ add it. - Click ⋮ at the top right of the TrackBuilder component → Rebuild Track. The track appears immediately.
If your track corners appear far from the
straight sections, the Track GameObject was
not at world origin when the track was built.
Select the Track GameObject,
click ⋮ on the Transform
→ Reset, then click
⋮ on TrackBuilder
→ Rebuild Track.
TrackBuilder only rebuilds when you explicitly click ⋮ → Rebuild Track. Changing parameter values in the Inspector does not auto-rebuild, so your scene references (including spawn point assignments) are preserved.
TrackBuilder parameters
| Parameter | Default | Description |
|---|---|---|
trackWidth | 20 | Road surface width in metres |
trackLength | 150 | Length of each straight section |
cornerRadius | 30 | Half-distance between the two parallel straights |
barrierHeight | 2 | Height of the wall barriers |
cornerSegments | 8 | Arc segments per 90° corner — increase for smoother curves |
numSpawnPoints | 4 | Number of spawn positions at the start line |
Materials — assign in Inspector or leave null for auto-defaults
TrackBuilder exposes four material slots in the Inspector: Track Material (road surface), Barrier Material (walls), Ground Material (infield), and Start Line Material. Leave any slot empty and a plain-colour material is created automatically. Assign your own materials to customise the track appearance.
// Assets/PivecLabs/Examples/Racing/Scripts/TrackBuilder.cs
// Full source — add to your project as-is.
// After attaching, click gear icon → "Rebuild Track" in the Inspector.
[ExecuteInEditMode]
public class TrackBuilder : MonoBehaviour
{
public float trackWidth = 20f;
public float trackLength = 150f;
public float cornerRadius = 30f;
public float barrierHeight = 2f;
public int numSpawnPoints = 4;
[ContextMenu("Rebuild Track")]
public void Build() { /* ... builds track from primitives ... */ }
}
The SpawnPoints child contains SpawnPoint_0 through SpawnPoint_N transforms you'll assign to RacingNetworkManager in Part B.
Car Controller¶
CarController.cs drives the ARCADE car using WheelCollider physics for the local player, and lerp-smoothing for remote players (see Part B5 for the remote mode detail).
ARCADE car hierarchy
ArcadeRacingCar (root) ← add CarController here
Body
Wheels
FrontLeft ← visual mesh only
FrontRight
RearLeft
RearRight
WC_FrontLeft ← WheelCollider (you create these)
WC_FrontRight
WC_RearLeft
WC_RearRight
The visual wheel transforms under Wheels/ are pure meshes. The WC_* objects are empty GameObjects with WheelCollider components. CarController syncs visual wheel world-pose to the colliders each frame.
Setup steps
- Drag the ARCADE car prefab into the scene. Right-click → Prefab → Unpack Completely.
- Select the car root GameObject.
- Click Add Component and search for CarController.
- That’s it —
CarControllerautomatically detects the existingWheelCollidersandRigidbody. No manual assignment needed.
CarController fields
| Field | Default | Description |
|---|---|---|
motorTorque | 1500 | Torque applied to rear drive wheels (N·m) |
brakeTorque | 3000 | Braking torque on Space key |
maxSteerAngle | 30° | Maximum front wheel steering angle |
maxSpeed | 60 m/s | Motor cuts out above this speed (~216 km/h) |
isLocalPlayer | true | Set to false for remote cars |
Smooth physics — auto-configured by CarController
CarController automatically applies recommended physics settings in Awake(): linear and angular damping, lowered centre of mass, Rigidbody interpolation, and continuous collision detection. WheelCollider forward and sideways friction stiffness is also tuned for arcade handling.
If the car feels too grippy or too slidey, adjust forwardFriction.stiffness and sidewaysFriction.stiffness in ConfigureWheelFriction() inside CarController.cs.
Input System — no Input Actions asset needed
CarController reads keyboard input directly via Keyboard.current from Unity’s new Input System. 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 in:
Edit → Project Settings → Player → Other Settings
using UnityEngine.InputSystem;
private void UpdateLocal()
{
if (Keyboard.current == null) return;
// Steering — A/D or Left/Right arrows
float steerInput = 0f;
if (Keyboard.current.aKey.isPressed || Keyboard.current.leftArrowKey.isPressed)
steerInput = -1f;
else if (Keyboard.current.dKey.isPressed || Keyboard.current.rightArrowKey.isPressed)
steerInput = 1f;
// Throttle — W/Up arrow; Brake/Reverse — S/Down arrow
float throttleInput = 0f;
if (Keyboard.current.wKey.isPressed || Keyboard.current.upArrowKey.isPressed)
throttleInput = 1f;
float brakeInput = 0f;
if (Keyboard.current.sKey.isPressed || Keyboard.current.downArrowKey.isPressed)
brakeInput = 1f;
// Handbrake — Space
bool handbrake = Keyboard.current.spaceKey.isPressed;
// W = forward (+1), S = reverse (−1)
float combinedThrottle = throttleInput - brakeInput;
float speed = _rb != null ? _rb.linearVelocity.magnitude : 0f;
float appliedTorque = speed < maxSpeed ? motorTorque * combinedThrottle : 0f;
foreach (var w in driveWheels)
{
w.motorTorque = appliedTorque;
w.brakeTorque = handbrake ? brakeTorque : 0f;
}
foreach (var w in steerWheels)
{
w.steerAngle = maxSteerAngle * steerInput;
w.brakeTorque = handbrake ? brakeTorque : 0f;
}
}
Follow Camera¶
CarCamera.cs provides a smooth third-person follow camera using SmoothDamp for position and Slerp for rotation. It feels weighty without being jittery.
- Select the Main Camera.
- Add Component → CarCamera.
- Target: assign the car root's Transform.
- Offset:
(0, 4, -9)— 4 m above, 9 m behind. - Smooth Time:
0.18
private void LateUpdate()
{
if (target == null) return;
Vector3 desiredPos = target.position + target.rotation * offset;
transform.position = Vector3.SmoothDamp(
transform.position, desiredPos, ref _velocity, smoothTime);
Quaternion desiredRot = Quaternion.LookRotation(
target.position - transform.position);
_currentRot = Quaternion.Slerp(
_currentRot, desiredRot, Time.deltaTime / rotationSmoothTime);
transform.rotation = _currentRot;
}
Single Player Test¶
Set up a minimal test scene before adding multiplayer.
- File → New Scene, save as
Assets/Scenes/RacingScene.unity. - Add a Directional Light if not present.
- Create a Track GameObject with TrackBuilder — click Rebuild Track.
- Drag the unpacked ARCADE car to the scene. Position at
SpawnPoint_0. - Set Main Camera → CarCamera.target = the car root's Transform.
Test controls
| Key | Action |
|---|---|
W / ↑ | Accelerate |
S / ↓ | Brake / reverse |
A/D / ←→ | Steer |
Space | Handbrake |
Press Play and drive around the track. If the car flips easily, lower motorTorque to 800 or increase Rigidbody mass. If steering is sluggish, increase maxSteerAngle to 35.
NetCrafter Schema¶
Open the NetCrafter window: Tools → Pivec Labs → NetCrafter Pro. Click New Schema and name it Racing.
SmartFox Settings tab
| Field | Value |
|---|---|
| Host | 127.0.0.1 |
| Zone Name | RacingZone |
| Extension Name | Racing |
| Extension Class | com.piveclabs.racing.RacingExtension |
| TCP Port | 9977 |
| WebSocket Port | 9977 |
| Auto-Reconnect | ✓ |
Extension Name and Extension Class are only required for server code generation (Part B2) and config generation (Part B3). The connection will work without them, but the server JAR and zone config won't be generated correctly.
Project Settings tab
| Field | Value |
|---|---|
| Namespace | Racing |
| Overwrite Files | ✓ |
Gameplay Messages tab — ClientToServer
Create one ClientToServer message named CarUpdate with these exact settings:
- Direction: ClientToServer
- Transport: ExtensionRequest
- Server Handler Name:
HandleCarUpdate
This message has 9 fields. Add them one by one using the + Add Field button:
| # | Field Name | Type | Is Array |
|---|---|---|---|
| 1 | posX | float | No |
| 2 | posY | float | No |
| 3 | posZ | float | No |
| 4 | rotX | float | No |
| 5 | rotY | float | No |
| 6 | rotZ | float | No |
| 7 | rotW | float | No |
| 8 | speed | float | No |
| 9 | colorIndex | int | No |
Set Server Handler Name to HandleCarUpdate BEFORE adding fields. The handler name links this message to the server action you will create in the next step.
Gameplay Messages tab — ServerToClient
Create three ServerToClient events:
Leave Server Handler Name blank for ServerToClient events
These events are sent by the server — they are not triggered by a client request, so no handler is needed. Setting a Handler Name on a ServerToClient event causes a validation error because no matching Server Action exists.
CarStateUpdate — Direction: ServerToClient, Transport: ExtensionEvent, Server Handler Name: (leave blank)
This event has 9 fields:
| # | Field Name | Type | Is Array |
|---|---|---|---|
| 1 | playerId | int | No |
| 2 | posX | float | No |
| 3 | posY | float | No |
| 4 | posZ | float | No |
| 5 | rotX | float | No |
| 6 | rotY | float | No |
| 7 | rotZ | float | No |
| 8 | rotW | float | No |
| 9 | speed | float | No |
PlayerJoined — Direction: ServerToClient, Transport: ExtensionEvent, Server Handler Name: (leave blank)
This event has 4 fields:
| # | Field Name | Type | Is Array |
|---|---|---|---|
| 1 | playerId | int | No |
| 2 | playerName | string | No |
| 3 | colorIndex | int | No |
| 4 | spawnIndex | int | No |
PlayerLeft — Direction: ServerToClient, Transport: ExtensionEvent, Server Handler Name: (leave blank)
This event has 1 field:
| # | Field Name | Type | Is Array |
|---|---|---|---|
| 1 | playerId | int | No |
Server Actions tab
Only one Server Action is needed — HandleCarUpdate:
| Field | Value |
|---|---|
| Handler Name | HandleCarUpdate |
| Trigger Message | CarUpdate |
| Response Event | CarStateUpdate |
| Logic Type | BroadcastAll |
This handles the incoming CarUpdate message from each client and broadcasts it as CarStateUpdate to all other players. PlayerJoined and PlayerLeft are handled automatically by SFS3's room lifecycle events — the generated Java extension will handle these in Part B2.
Sync Rules tab
Create two sync rules:
| # | Name | Target | Mode | Interval |
|---|---|---|---|---|
| 1 | CarPosition | Transform_Position | Interval | 50 ms |
| 2 | CarRotation | Transform_Rotation | Interval | 50 ms |
These rules document the intended sync cadence. The actual network sending is handled by RacingNetworkManager, which bundles position and rotation together in a single CarUpdate message every 0.05 seconds — more efficient than sending them as separate messages.
Interval units
The Interval field is in milliseconds. Common values:
- 20 ms = 0.02 s = 50 updates/second (very smooth — high bandwidth)
- 50 ms = 0.05 s = 20 updates/second (recommended for fast-moving objects like cars)
- 100 ms = 0.1 s = 10 updates/second (suitable for slower objects or chat presence)
Lower intervals mean smoother movement but more network traffic. 50 ms (20 Hz) is a good balance for racing games.
Generate All Code¶
Generate client code
In the Code Generation tab, click Generate Client Code. Generated files:
Definitions/Racing/Generated/Client/ SmartFoxConnectionManager.cs ← connection + event dispatch CarUpdateRequest.cs ← typed request sender CarStateUpdateEvent.cs ← typed event receiver PlayerJoinedEvent.cs PlayerLeftEvent.cs
LocalUserId and LocalUserName
LocalUserId and LocalUserName are automatically included in the generated SmartFoxConnectionManager — no manual editing needed. RacingNetworkManager uses LocalUserId to identify the local player and assign colour/spawn index.
Enable network send/receive — add second scripting define
Now that SmartFoxConnectionManager exists, unlock the network code in the racing scripts by adding a second scripting define:
- Go to Edit → Project Settings → Player
- Find Scripting Define Symbols (under Other Settings)
- Add:
NETCRAFTER_RACING_GENERATED - Click Apply
This enables SmartFoxConnectionManager references in RacingNetworkManager.cs and RacingUIManager.cs — login, the 20 Hz position send loop, and local player ID assignment.
Generate server code
Click Generate Server Code. Generated files:
Definitions/Racing/Generated/Server/src/main/java/com/piveclabs/racing/
RacingExtension.java
handlers/
HandleCarUpdate.java ← reads position, broadcasts CarStateUpdate
Join/leave handling — auto-generated
The generated RacingExtension.java automatically handles all room lifecycle events — no manual editing needed:
- Player join: sends
PlayerJoinedto all existing players, and sends aPlayerJoinedfor each existing player to the joining user - Player leave: sends
PlayerLeftto all remaining players - Player disconnect: sends
PlayerLeftto all remaining players in the disconnected user's last room
Build and deploy the JAR
Click Deploy to SFS3 in the NetCrafter window → Code → Server Code tab.
This builds RacingExtension.jar and copies it to SmartFoxServer3/server/extensions/Racing/.
JAR naming
SFS3 AdminTool only discovers JARs whose filename ends in Extension.jar. The generated pom.xml is already configured to produce RacingExtension.jar.
Car Colour System¶
CarColorManager.cs applies a player colour to all body renderers on a car. Renderers whose gameObject.name contains "wheel" are skipped.
public class CarColorManager : MonoBehaviour
{
public static readonly Color[] PlayerColors = new Color[]
{
Color.red,
Color.blue,
Color.green,
Color.yellow,
new Color(1f, 0.5f, 0f), // orange
Color.magenta,
Color.cyan,
Color.white,
};
public void SetColor(int colorIndex)
{
Color c = PlayerColors[colorIndex % PlayerColors.Length];
foreach (var r in GetComponentsInChildren<Renderer>())
{
if (r.gameObject.name.ToLower().Contains("wheel")) continue;
r.material.color = c;
}
}
}
colorIndex is assigned by the server as playerId % 8. Add CarColorManager to the car prefab so both local and remote cars can be coloured at spawn time.
Scene Setup¶
Complete B4.1
and B4.2
before wiring any Scene Events or adding
RacingNetworkManager. The
NetCrafterEventDispatcher
must be in the scene before Scene Events
will work.
B4.1 — Create ConnectionManager Prefab
- Open the NetCrafter window → Scenes tab → Prefab Generator.
- Click Create ConnectionManager Prefab.
- Confirm the prefab was created at:
Definitions/Racing/Generated/Prefabs/ConnectionManager.prefab
B4.2 — Create Multiplayer Scene
- Open the NetCrafter window → Scenes tab → Scene Assistant.
- Confirm that the ConnectionManager prefab is detected.
- Ensure the EventDispatcher toggle is checked.
- Click Create Multiplayer Scene and name it
RacingScene.
If your scene is already set up, click Add to Existing Scene instead.
The Scene Assistant adds the following GameObjects:
ConnectionManager(from generated prefab)NetCrafterEventDispatcherNetworkManager(empty GameObject — configured in B4.5)Main CameraSpawnPoints
B4.3 — Verify Scene Hierarchy
After Scene Assistant completes, your Hierarchy should contain:
ConnectionManagerNetCrafterEventDispatcherNetworkManager(empty —RacingNetworkManagercomponent added in B4.5)SpawnPointswith childrenSpawnPoint_0throughSpawnPoint_3Track(from Part A)Car(car prefab — assigned in B4.5)Main Camera
B4.4 — Wire Scene Events
Open the NetCrafter window → Scene Events tab for the Racing schema. Wire these three events:
| Event | Target Object | Method |
|---|---|---|
PlayerJoined | NetworkManager | RacingNetworkManager.OnPlayerJoined |
PlayerLeft | NetworkManager | RacingNetworkManager.OnPlayerLeft |
CarStateUpdate | NetworkManager | RacingNetworkManager.OnCarStateUpdate |
The generated SmartFoxConnectionManager routes each incoming SFS3 extension event to the wired Unity method. Inside each method, the event payload is read via NetCrafterSceneEventRouter.Instance.GetLastPayload(eventName).
B4.5 — Add RacingNetworkManager
RacingNetworkManager.cs is the hub for all multiplayer logic. It:
- Finds the existing scene car (placed in Part A) and uses it as the local player car
- Spawns remote cars from
Car PrefabonPlayerJoined - Destroys remote cars on
PlayerLeft - Updates remote car target transforms on
CarStateUpdate - Sends
CarUpdateat 20 Hz viaSendExtensionRequest
Scene car vs Car Prefab
The car you placed in the scene during Part A is used as your local player car. RacingNetworkManager finds it automatically via FindAnyObjectByType<CarController>() — do NOT delete it before entering multiplayer.
Remote players' cars are instantiated from the Car Prefab field. You need a prefab version of the same car so RacingNetworkManager can spawn one for each remote player who joins.
Create a Car Prefab from your scene car
- In the Hierarchy, select your Car GameObject.
- Drag it into the Project window under
Assets/PivecLabs/Examples/Racing/Prefabs/(create the folder if needed). - Name the prefab CarPrefab.
- The original car remains in the scene — Unity creates the prefab as a copy. Do not remove the scene car.
Add RacingNetworkManager to the scene
- Select the
NetworkManagerGameObject created in B4.2. - Add Component → RacingNetworkManager.
- Car Prefab: assign the
CarPrefabyou just created. This is used for remote players only — the scene car is used for the local player. - Spawn Points: drag in the
SpawnPoint_0throughSpawnPoint_3transforms from the TrackBuilder's SpawnPoints child.
After adding RacingNetworkManager to the scene, drag the SpawnPoint_0 through SpawnPoint_3 GameObjects from the Hierarchy into the Spawn Points array in the Inspector. These references are saved with the scene — you only need to assign them once.
Car prefab checklist
- ✅
CarController— all wheel references assigned,isLocalPlayerstarts astrue(set tofalseat runtime for remote cars) - ✅
CarColorManager— no config needed - ✅
Rigidbody— Mass: 1200 - ✅ Four
WheelColliderchildren configured
Save as Assets/PivecLabs/Examples/Racing/Prefabs/CarPrefab.prefab.
Typed events — generated after Part B2
RacingNetworkManager.cs uses the typed event classes generated in Part B2 (PlayerJoinedEvent, PlayerLeftEvent, CarStateUpdateEvent). These are only available after running Generate Client Code and adding NETCRAFTER_RACING_GENERATED. The #if NETCRAFTER_RACING_GENERATED guards in each callback ensure the scripts compile cleanly both before and after generation.
// Scene Event callbacks — wired in the NetCrafter Scene Events tab
// Typed events are available after Generate Client Code + NETCRAFTER_RACING_GENERATED.
public void OnPlayerJoined()
{
#if NETCRAFTER_RACING_GENERATED
var payload = NetCrafterSceneEventRouter.Instance?
.GetLastPayload("PlayerJoined");
if (!(payload is PlayerJoinedEvent ev)) return;
int localId = SmartFoxConnectionManager.Instance?.LocalUserId ?? -1;
if (ev.playerId == localId) return; // ignore echo of own join
if (_remoteCars.ContainsKey(ev.playerId)) return; // duplicate guard
SpawnRemoteCar(ev.playerId, ev.playerName, ev.colorIndex, ev.spawnIndex);
FindAnyObjectByType<RacingUIManager>()?.AddPlayerToList(ev.playerName);
#endif
}
public void OnPlayerLeft()
{
#if NETCRAFTER_RACING_GENERATED
var payload = NetCrafterSceneEventRouter.Instance?
.GetLastPayload("PlayerLeft");
if (!(payload is PlayerLeftEvent ev)) return;
string name = _remotePlayerNames.TryGetValue(ev.playerId, out var n) ? n : "";
RemoveRemoteCar(ev.playerId);
if (!string.IsNullOrEmpty(name))
FindAnyObjectByType<RacingUIManager>()?.RemovePlayerFromList(name);
#endif
}
public void OnCarStateUpdate()
{
#if NETCRAFTER_RACING_GENERATED
var payload = NetCrafterSceneEventRouter.Instance?
.GetLastPayload("CarStateUpdate");
if (!(payload is CarStateUpdateEvent ev)) return;
if (ev.playerId == _localPlayerId) return;
UpdateRemoteCar(
ev.playerId,
new Vector3(ev.posX, ev.posY, ev.posZ),
new Quaternion(ev.rotX, ev.rotY, ev.rotZ, ev.rotW),
ev.speed);
#endif
}
Remote Car Lerp¶
When CarController.isLocalPlayer = false:
- The
Rigidbodyis set to kinematic — no local physics simulation - All
WheelCollidercomponents are disabled - Each
Update()lerps position and rotation toward the latest network values - Visual wheels are animated from
networkSpeed(spin only — steering angle isn't part ofCarStateUpdate, so remote front wheels don't visually turn)
private void UpdateRemote()
{
transform.position = Vector3.Lerp(
transform.position,
networkTargetPosition,
Time.deltaTime * 10f);
transform.rotation = Quaternion.Slerp(
transform.rotation,
networkTargetRotation,
Time.deltaTime * 10f);
// Accumulate wheel spin angle from received speed
_wheelSpinAngle += networkSpeed * Time.deltaTime * (360f / (2f * Mathf.PI * 0.35f));
}
A lerp factor of 10 means the car visually reaches its target within ~100 ms. At 20 updates per second, remote cars look smooth even on a typical LAN with <50 ms ping.
Login and Lobby UI¶
The UI has three panels on a single Canvas. Only one panel is visible at a time.
Create the Canvas and panels
- GameObject → UI → Canvas. Set Render Mode to Screen Space — Overlay.
- Inside the Canvas, create three empty child GameObjects:
LoginPanel,WaitingPanel,RacePanel.
LoginPanel layout
| Element | Type | Notes |
|---|---|---|
| Title | TMP_Text | "Racing Multiplayer" |
| UsernameInput | TMP_InputField | Placeholder: "Enter your name..." |
| LoginButton | Button | onClick → RacingUIManager.OnLoginClicked |
WaitingPanel layout
| Element | Type | Notes |
|---|---|---|
| Title | TMP_Text | "Waiting for players..." |
| PlayerListText | TMP_Text | Updated by AddPlayerToList() |
| StartRaceButton | Button | onClick → OnStartRaceClicked; hidden until 2+ players |
RacePanel layout
| Element | Type | Notes |
|---|---|---|
| SpeedText | TMP_Text | Shows "120 km/h" |
| PositionText | TMP_Text | Shows "1st", "2nd"… |
| LapText | TMP_Text | Shows "Lap 1/3" |
Wire up RacingUIManager
- Create an empty GameObject, name it
UIManager. - Add Component → RacingUIManager.
- Assign all panel GameObjects and UI element references in the Inspector.
- Set
LoginButton.onClick→RacingUIManager.OnLoginClicked. - Set
StartRaceButton.onClick→RacingUIManager.OnStartRaceClicked.
Scene Events Wiring¶
Open the NetCrafter window → Scene Events tab for the Racing schema. Wire these three events:
| Event | Target Object | Method |
|---|---|---|
PlayerJoined | NetworkManager | RacingNetworkManager.OnPlayerJoined |
PlayerLeft | NetworkManager | RacingNetworkManager.OnPlayerLeft |
CarStateUpdate | NetworkManager | RacingNetworkManager.OnCarStateUpdate |
The generated SmartFoxConnectionManager routes each incoming SFS3 extension event to the wired Unity method. Inside each method, the event payload is read via NetCrafterSceneEventRouter.Instance.GetLastPayload(eventName).
SFS3 Zone Config¶
The zone file RacingZone.zone.xml must be in SmartFoxServer3/server/zones/. If you followed this tutorial, it was created automatically. The key values:
<zone>
<name>RacingZone</name>
<allowGuestUsers>true</allowGuestUsers>
<guestUserNamePrefix>Guest#</guestUserNamePrefix>
<maxUsers>100</maxUsers>
<wordsFilter active="false"/>
<floodFilter active="false"/>
<rooms>
<room>
<name>GameRoom</name>
<maxUsers>16</maxUsers>
<isGame>false</isGame>
<!-- ... full room structure with mmoSettings, roomVariables stubs ... -->
</room>
</rooms>
<privilegeManager active="false"/>
<extension>
<name>Racing</name>
<type>JAVA</type>
<file>com.piveclabs.racing.RacingExtension</file>
<propertiesFile></propertiesFile>
<reloadMode>AUTO</reloadMode>
</extension>
<buddyList active="false"/>
<databaseManager active="false"/>
<udpSettings>
<keepAlive>true</keepAlive>
<rdpQualitySetting>BEST_EFFORT</rdpQualitySetting>
<rdpReliableChannelType>2</rdpReliableChannelType>
</udpSettings>
</zone>
⚠️ Every stub element is required. SFS3 3.0.2 throws NullPointerException on startup if wordsFilter, floodFilter, privilegeManager, buddyList, databaseManager, or udpSettings are absent — even when disabled.
Full Test Sequence¶
1. Start SFS3
Launch sfs3-standalone.exe. Wait for the console to show the extension loaded. In AdminTool, Zones → RacingZone → Extensions should show Racing in green.
2. Player 1 (Unity Editor)
- Press Play.
- The LoginPanel appears. Enter a name (e.g.,
Driver1) and click Login. - The WaitingPanel appears. "Waiting for players..."
3. Player 2 (Build or second instance)
Option A — File → Build and Run a standalone build, then run the .exe alongside Unity.
Option B — run a second Unity instance from the same project on the same machine.
Enable Run In Background
When testing two instances on the same machine, the Unity Editor pauses network processing when it loses focus. Enable Run In Background so both instances keep sending and receiving while you switch windows:
Edit → Project Settings → Player → Resolution and Presentation → Run In Background ✓
- Enter a different name (e.g.,
Driver2) and click Login. - Both WaitingPanels now show Start Race.
4. Expected results
| What to check | Expected |
|---|---|
| Car colours | Driver1 = red (index 0), Driver2 = blue (index 1) |
| Remote car visible | Each player sees the other's car at the correct spawn point |
| Remote car movement | Moves with smooth lerp — no teleporting |
| Disconnect | Closing one client removes its car from the other's scene |
Debugging tips
- Car doesn't appear: Confirm
PlayerJoinedis wired in Scene Events andNetworkManageris active in the scene hierarchy. - Remote car teleports: The lerp factor is applied per-frame — ensure
Time.deltaTimeis being used (it is in the provided code). - Build failed: Check the Tool Detection panel in the Server Code tab — it shows the Maven and JDK paths that will be used, detected live each time the tab is drawn.
- AdminTool shows extension in red: Check SFS3 console for Java stack trace — usually a missing import or wrong FQN in the zone XML.
What's Next¶
The multiplayer foundation is in place. Here are natural next features: