Synchronized Multiplayer Vehicles in C#
This tutorial demonstrates how to create network-synchronized vehicles using an event-driven system. The example shows how to handle player inputs, vehicle physics, and state synchronization across the network.
Key Concepts
- Network Synchronization: Keeping vehicle states consistent across all clients
- Input Handling: Processing both desktop and VR controls
- Physics Simulation: Realistic wheel collider behavior
- Audio Feedback: Engine sounds that respond to vehicle state
This outlines the core pillars of the system: network sync (keeping vehicles in sync across players), input handling (supporting both desktop/keyboard and VR controls), physics (realistic wheel collider behavior), and audio (dynamic engine sounds). These work together to create a believable multiplayer vehicle.
Multiplayer vehicles need to feel consistent for all players while minimizing network strain. By focusing on syncing only critical inputs (throttle/steering) rather than full physics states, we reduce bandwidth while maintaining deterministic behavior. Separate control schemes for desktop and VR exist because these input methods have fundamentally different constraints VR joysticks lack the precision of keyboards, requiring curved steering adjustments. The layered audio system mirrors real-world engines where sound isn’t monolithic but a blend of components reacting to RPM changes.
Constants and Fields
Here we start to define critical values like engine RPM limits and references to Unity components (wheel colliders, rigidbody, etc.). The syncObject
is highlighted as the network-synchronized data carrier for throttle/steering/direction states.
The hardcoded engine RPM limits (800–8000) prevent unrealistic behavior while matching expectations for automotive physics. We use Unity’s WheelCollider
instead of custom raycasts because it handles tire friction and suspension automatically, and the syncObject
transform is intentionally repurposed as a network data carrier. Transforms sync efficiently by default in Unity, making them ideal for compressing multiple inputs (throttle/steering/direction) into a single networked property.
// Engine constants
private const float NEAR_ZERO = 0.01f;
private const float ENGINE_IDLE_RPM = 800f;
private const float ENGINE_MAX_RPM = 8000f;
// Network sync object
public Transform syncObject; // Used to synchronize state across network
// Vehicle components
public WheelCollider wheelFR;
public WheelCollider wheelFL;
public WheelCollider wheelBR;
public WheelCollider wheelBL;
public Rigidbody Car_RB;
public MLStation station; // The seat/station controlling the vehicle
// Internal state
private float throttle = 0;
private float steering = 0;
private int direction = 1;
private bool underLocalPlayerControl = false;
Initialization
As we initialize our vehicles, we set up the vehicle's physical properties (wheelbase, center of mass) and hooks into the seating system (MLStation
). Initializes engine audio layers (idle, acceleration) that will dynamically adjust later.
Calculating wheelbase and track measurements upfront is necessary for accurate Ackermann steering math later. Lowering the center of mass in the rigidbody prevents the vehicle from rolling over during sharp turns, a common issue in arcade-style physics. The audio clips start playing immediately (but silently) because Unity’s AudioSource
components introduce noticeable latency if activated mid-gameplay.
void Start()
{
// Set up vehicle physics
wheelBase = Vector3.Distance(wheelBL.transform.position, wheelFR.transform.position);
track = Vector3.Distance(wheelFL.transform.position, wheelFR.transform.position);
// Set center of mass for stability
Car_RB.centerOfMass = centerOfMass.localPosition;
// Set up station listeners
station.OnPlayerSeated.AddListener(OnPlayerEnterStation);
station.OnPlayerLeft.AddListener(OnPlayerLeftStation);
// Initialize audio
engineIdleAudio.Play();
engineAccelerationAudio.Play();
engineDriveAudio.Play();
windAudio.Play();
}
Network Synchronization
Uses syncObject
to share control inputs between clients. The local player writes their inputs to it, while remote players read from it. This avoids direct RPC calls, reducing network overhead.
Syncing raw inputs instead of vehicle transforms might seem counterintuitive, but it’s a tradeoff for scalability. While syncing positions directly would guarantee visual consistency, it’s bandwidth-heavy and prone to jitter. By having each client simulate physics locally using the same inputs, we ensure smooth movement while keeping network traffic minimal. The underLocalPlayerControl
flag acts as a failsafe. It prevents remote players from overwriting the driver’s authoritative inputs, which could cause desyncs.
void Update()
{
if (underLocalPlayerControl)
{
// Local player controls - update sync object
syncObject.localPosition = new Vector3(throttle, steering, direction);
}
else
{
// Remote player - read from sync object
var localPos = syncObject.localPosition;
throttle = localPos.x;
steering = localPos.y;
direction = (int)localPos.z;
}
}
Input Handling
- Desktop Controls: Uses keyboard inputs for throttle/steering with gradual acceleration/deceleration. Handbrake is mapped to the jump key.
- VR Controls: Leverages analog controller input with optional "curved steering" for more natural movement. Throttle direction auto-corrects based on speed.
Throttle control uses gradual acceleration/deceleration because instant 0-to-max torque would make vehicles feel unnaturally jerky. For VR, the curved steering calculation exists purely for usability. Raw joystick input would require tiny precise movements for straight-line driving, frustrating players. The automatic direction switch (between forward/reverse) mirrors real automatic transmissions, eliminating the need for manual gear toggling during parking maneuvers.
private void HandleDesktopModeInput(UserStationInput input)
{
// Throttle control
if (input.KeyboardMove.y > 0.5f)
{
throttle += THROTTLE_FACTOR;
throttle = Mathf.Clamp(throttle, -1, 1);
}
// Steering control
if (input.KeyboardMove.x > 0.5f)
{
steering += STEERING_FACTOR;
}
// Handbrake
if (input.Jump)
{
wheelBL.brakeTorque = handBreakTorque;
wheelBR.brakeTorque = handBreakTorque;
}
}
VR Controls
private void HandleVRModeInput(UserStationInput input)
{
// Throttle from controller analog input
throttle = input.LeftControl.y;
// Curved steering for more natural feel
float steeringTarget = useCurvedSteeringInVR ?
Mathf.Tan(input.RightControl.x / 0.685f) * 0.113f :
input.RightControl.x;
steering = Mathf.MoveTowards(steering, steeringTarget, 1.5f * Time.deltaTime);
}
Physics Simulation
Ackermann steering isn’t just for realism. It prevents wheel scrubbing in tight turns, which would otherwise cause visual glitches and loss of traction in the simulation. Local players calculate speed via wheel RPM because it’s precise and responsive, while remote players use position deltas as a fallback (since they don’t have direct access to the driver’s wheel collider data). The motor torque calculation factors in gear ratios because lower gears should feel torque-heavy while higher gears prioritize speed, matching real transmission behavior.
void FixedUpdate()
{
// Calculate speed
speed = underLocalPlayerControl ?
(wheelFL.rpm * wheelFL.radius * Mathf.PI * 2) * 0.06f :
Vector3.Distance(lastPost, transform.position) / Time.fixedDeltaTime;
// Apply motor torque
if (breaks < NEAR_ZERO)
{
float rpmRelativeT = carEngine.GetEngineTorque(carEngine.rpm);
float wheelTorque = throttle * rpmRelativeT * MOTOR_MAX_TORQUE * gearRatio;
wheelBL.motorTorque = wheelTorque;
wheelBR.motorTorque = wheelTorque;
}
// Ackermann steering geometry
if (useAckerman)
{
CalculateAckermannAngles();
}
else
{
wheelFR.steerAngle = steering * STEERING_ANGLE_MAX;
wheelFL.steerAngle = steering * STEERING_ANGLE_MAX;
}
}
Player Events
The 3-second engine startup delay isn’t just cosmetic! It prevents players from immediately speeding off after entering a station, which could cause network race conditions if they exit prematurely. Applying the handbrake on exit is a failsafe; without it, networked vehicles might continue rolling indefinitely if the last driver’s inputs weren’t fully synced before disconnection.
private void OnPlayerEnterStation()
{
// Start engine
engineStartupAudio.Play();
carEngine.state = EngineState.Startup;
// Enable physics
Car_RB.drag = 0;
Car_RB.angularDrag = 0;
// Set current driver
currentDriver = station.GetPlayer();
underLocalPlayerControl = currentDriver.IsLocal;
}
private void OnPlayerLeftStation()
{
// Apply handbrake
wheelBL.brakeTorque = handBreakTorque;
wheelBR.brakeTorque = handBreakTorque;
// Turn off engine
carEngine.state = EngineState.Off;
currentDriver = null;
underLocalPlayerControl = false;
}
Audio Feedback
Blending multiple audio layers (idle/accel/drive) based on RPM creates a more convincing engine sound than a single looping clip ever could. Adjusting pitch with RPM is subtle but critical. It’s why sports cars sound like they’re "winding up" during acceleration. Wind noise scales with speed because it’s a cheap but effective way to sell velocity without expensive physics calculations.
private void HandleEngineAudioEffects()
{
float rpmFactor = Mathf.InverseLerp(ENGINE_IDLE_RPM, ENGINE_MAX_RPM, carEngine.rpm);
// Adjust volumes based on engine state
engineAccelerationAudio.volume = throttle;
engineAccelerationAudio.pitch = Mathf.Lerp(0.8f, 1.5f, rpmFactor);
engineIdleAudio.volume = Mathf.Lerp(1, 0, rpmFactor);
engineDriveAudio.volume = Mathf.Lerp(0.2f, 1, rpmFactor);
}
A Brief Look at Wheel Colliders in Unity
The Unity WheelCollider is a specialized collider for simulating vehicle wheels. It provides realistic physics interactions, including motor torque, braking, steering, and suspension. Key features include configuring suspension distance, spring rate, damper rate, and target position for suspension behavior. The WheelCollider can apply motor torque for acceleration, brake torque for deceleration, and adjust steer angle for turning. It also computes friction based on forward and sideways stiffness curves.
Wheel Colliders are specialized physics components that simulate realistic wheel behavior including:
- Suspension
- Tire friction
- Motor / brake torque forces
- Steering
To use it, attach the WheelCollider to a GameObject (typically an empty one) and link it to a visible wheel via the wheelVisual or manual transforms. The GetWorldPose method outputs the wheel’s world position and rotation for synchronization with the visual model.
This WheelController
script synchronizes a vehicle's wheel physics and visuals across a multiplayer environment while handling effects like tire slipping and audio feedback. It bridges Unity’s WheelCollider
(which handles suspension and friction) with a visible 3D wheel model, ensuring the physics simulation matches what players see. The script distinguishes between locally controlled vehicles (which calculate precise physics) and remote vehicles (which approximate wheel motion for efficiency). Key features include real-time slipping detection, dynamic audio/particle effects, and network-friendly optimizations.
Designed as a starting point, the script prioritizes modularity. Its separation of physics, visuals, and effects makes it adaptable. For example, you could extend it to support advanced traction systems or tire deformation without refactoring the core logic. This script provides the foundation for networked wheel physics, leaving room for customization based on your game’s or environment's specific requirements, whether that’s tweaking slip values for drift-heavy gameplay or integrating with a proprietary networking solution.
Best Practices
These aren’t arbitrary rules – they’re solutions to common multiplayer pitfalls:
-
Network Efficiency:
- Only sync essential variables (throttle, steering, direction)
- Use a single sync object rather than multiple network calls
-
Physics:
- Let the master client handle complex physics calculations (or the object's owner, you can set particular users to be owners of objects)
- Use wheel colliders for realistic behavior
-
Input:
- Provide different control schemes for desktop and VR
- Implement input smoothing for better feel
-
Audio:
-
Layer multiple audio sources for realistic engine sounds
-
Adjust pitch and volume based on RPM and speed
-
Complete Example
The full script ties all segments together into a single, networked vehicle class. It’s designed for the Massive Loop framework but demonstrates universal concepts like input sync and physics authority and serves as a great example of how to handle user input through an MLStation! Each segment contributes to a responsive, network-efficient vehicle that looks, performs, and most importantly feels consistent for all players.
This structure exists because multiplayer systems thrive on separation of concerns. Input, physics, and rendering each have isolated responsibilities. It demonstrates how to cleanly couple vehicles with player seating systems for any networking framework. Ultimately, the complete script prioritizes predictability: All clients see identical behavior because they start from the same inputs, even if their local simulations run slightly out of sync.
To view the full example C# script, click here!
This implementation provides a solid foundation for synchronized multiplayer vehicles that:
- Works in both desktop and VR mode
- Handles vehicular network synchronization efficiently
- Provides realistic physics and audio feedback
- Supports multiple players entering/exiting the vehicle
Remember to test with multiple clients to ensure smooth synchronization and adjust network update rates as needed for your specific game requirements.