Project Length:
Project Type:
Software used:
Languages used:
Primary Role:
3 Weeks
School / Solo Project
Unity 6
C#
Solo Developer
This prototype was made during my 2nd year at Futuregames for our Advanced Scripting Language course. During this project I focused mainly on the car's state pattern and designing fun car physics.
For this project I wanted to challenge myself to create a custom vehicle physics system with an arcade casual feel. To do this I simulated the suspension forces and the traction of each wheel with the help of raycasts instead of colliders, as Unity's Hinge Joints and Wheel Colliders are incredibly buggy and inconsistent. Raycasts provided a performant and very tweakable system that simulates the wheel physics very well. Combined with the data-driven design this allowed me to create a modular vehicle system that allows for making multiple vehicles with very distinct handling qualities without needing to write additional code. The following code snippet is of the "Wheel" class and the "WheelVariables" struct, and shows the Vector math used to calculate the wheel forces.
using UnityEngine;
[System.Serializable]
public struct WheelVariables
{
public bool isPowered;
public float springStrength;
public float springDamping;
public float wheelGripFactor;
public float tireMass;
public float restSuspension;
public float maxSuspension;
public float jumpRestSuspension;
public float jumpMaxSuspension;
[HideInInspector]
public float currentRestSuspension;
[HideInInspector]
public float currentMaxSuspension;
public AnimationCurve torqueCurve;
public AnimationCurve tractionAtSpeedCurve;
public float accMultiplier;
}
public class Wheel
{
private readonly Rigidbody _rb;
private readonly WheelVariables _data;
private readonly float _topSpeed;
public float CurrentRestSuspension { get; set; }
public float CurrentMaxSuspension { get; set; }
public float WheelGripFactor { get; set; }
public Wheel(WheelVariables data, Rigidbody rb, float topSpeed)
{
_data = data;
_rb = rb;
_topSpeed = topSpeed;
WheelGripFactor = _data.wheelGripFactor;
CurrentRestSuspension = _data.restSuspension;
CurrentMaxSuspension = _data.maxSuspension;
}
public Vector3 CalculateWheelForces(RaycastHit hit, Transform tireTransform, float accInput)
{
Vector3 wheelPos = tireTransform.position;
Vector3 springDir = tireTransform.up;
Vector3 wheelWorldVel = _rb.GetPointVelocity(wheelPos);
float offset = CurrentRestSuspension - hit.distance;
float vel = Vector3.Dot(springDir, wheelWorldVel);
float springForce = (offset * _data.springStrength) - (vel * _data.springDamping);
Vector3 totalForce = springDir * springForce;
Vector3 steeringDir = tireTransform.right;
float steeringVel = Vector3.Dot(steeringDir, wheelWorldVel);
float slipFactor = Mathf.Clamp01(Mathf.Abs(steeringVel));
float slipEvaluation = _data.tractionAtSpeedCurve.Evaluate(slipFactor);
float desiredVelChange = -steeringVel * WheelGripFactor * slipEvaluation;
float desiredAccel = desiredVelChange / Time.fixedDeltaTime;
totalForce += steeringDir * (_rb.mass * 0.25f * desiredAccel);
if (_data.isPowered)
{
Vector3 accDir = tireTransform.forward;
float carSpeed = Vector3.Dot(_rb.transform.forward, _rb.linearVelocity);
float normalizedSpeed = Mathf.Clamp01(Mathf.Abs(carSpeed / _topSpeed));
float availableTorque = _data.torqueCurve.Evaluate(normalizedSpeed) * accInput * _data.accMultiplier;
totalForce += accDir * availableTorque;
float rollingResistance = -carSpeed * (WheelGripFactor * 0.5f);
totalForce += accDir * rollingResistance;
}
return totalForce;
}
}


To handle player controls and movement, I used a State Pattern to more easily define custom behavior depending on the context and circumstance. The StateMachine script itself is quite simple, as all the logic is in the individual states. I modified the standard flow of State Patterns a bit for this project (or at least the way I learned State Patterns). Instead of defining all the behavior in all of the individual states, I wrote a "default" set of behaviors inside of the State Class, and when relevant the Child States override those behaviors. I did this to try and downsize the amount of work and boilerplate code I would have to write in each individual states, and because Cars generally behave in the same way most of the time. The three states that differ most with the default behaviors are the GameOverState, the AirState, and the DriftState. Making custom behavior for these states was incredibly easy, as I just had to override the methods I didn't want to use and write new logic in them.
The
The code snippets below are from the StateMachine class, the State class, the AirState class, and finally the PlayerCharacter class (the car).
using UnityEngine;
public class StateMachine
{
public State currentState;
public void InitializeState(State state)
{
currentState = state;
currentState.EnterState();
}
public void ChangeState(State newState)
{
if (!Application.isPlaying) return;
currentState.ExitState();
Debug.Log($"{currentState} Exiting");
currentState = newState;
if (currentState != null) currentState.EnterState();
Debug.Log($"{currentState} Entering");
}
}using Interfaces;
using UnityEngine.InputSystem;
public class State : IInputReceptor
{
protected PlayerCharacter player;
protected StateMachine SM;
protected State(PlayerCharacter _player, StateMachine _stateMachine)
{
player = _player;
SM = _stateMachine;
}
public virtual void EnterState()
{
player.inputService.EnableInputActions(player.playerInput.actions,this);
if (StatePrint.instance)StatePrint.instance.Print(this.ToString());
}
public virtual void ExitState()
{
player.inputService.DisableInputActions(player.playerInput.actions,this);
}
public virtual void LogicTick()
{
}
public virtual void PhysicsTick()
{
}
public virtual void OnMove(InputAction.CallbackContext context)
{
player.AccInput = context.ReadValue<float>();
}
public virtual void OnTurn(InputAction.CallbackContext context)
{
player.RotInput = context.ReadValue<float>();
}
public virtual void OnJump(InputAction.CallbackContext context)
{
if (context.performed)
{
player.rearWheels.currentMaxSuspension = player.rearWheels.data.jumpMaxSuspension;
player.rearWheels.currentRestSuspension = player.rearWheels.data.jumpRestSuspension;
player.frontWheels.currentMaxSuspension = player.frontWheels.data.jumpMaxSuspension;
player.frontWheels.currentRestSuspension = player.frontWheels.data.jumpRestSuspension;
}
else if (context.canceled)
{
player.rearWheels.currentMaxSuspension = player.rearWheels.data.maxSuspension;
player.rearWheels.currentRestSuspension = player.rearWheels.data.restSuspension;
player.frontWheels.currentMaxSuspension = player.frontWheels.data.maxSuspension;
player.frontWheels.currentRestSuspension = player.frontWheels.data.restSuspension;
}
}
public virtual void OnDrift(InputAction.CallbackContext context)
{
if (context.performed)
{
if (SM.currentState != player.airState) SM.ChangeState(player.driftState);
}
else
{
if (SM.currentState != player.airState) SM.ChangeState(player.defaultState);
}
}
public virtual void OnLook(InputAction.CallbackContext context)
{
}
public virtual void OnRespawn(InputAction.CallbackContext context)
{
}
}public class AirState : State
{
public AirState(PlayerCharacter _player, StateMachine _stateMachine) : base(_player, _stateMachine)
{
player = _player;
SM = _stateMachine;
}
public override void EnterState()
{
base.EnterState();
player.rb.linearDamping = 0;
}
public override void ExitState()
{
base.ExitState();
player.rb.linearDamping = 1;
}
public override void LogicTick()
{
base.LogicTick();
player.TryKeepUpright();
}
public override void PhysicsTick()
{
player.PhysicsTick();
}
}using Interfaces;
using UnityEngine;
using UnityEngine.InputSystem;
public class PlayerCharacter : MonoBehaviour, IGameOverListener
{
[Header("Configuration Assets")]
public CarData carData;
public WheelData frontWheelData;
public WheelData rearWheelData;
#region State Pattern
private StateMachine SM;
public DefaultState defaultState;
public DrivingState drivingState;
public DriftState driftState;
public AirState airState;
public BrakeState brakeState;
#endregion
[Header("References")]
public GameObject cameraTarget;
public PlayerInput playerInput;
public IInputService inputService;
public Rigidbody rb;
[Header("Wheel Transforms")]
public Transform Rear_L;
public Transform Rear_R;
public Transform Front_L;
public Transform Front_R;
public GameObject[] tireMeshes;
[HideInInspector] public float AccInput;
[HideInInspector] public float RotInput;
public Wheel rearWheels;
public Wheel frontWheels;
private Transform[] tires;
private Vector3[] tireForces;
private float m_CurrentSteerAngle;
private float _groundResetBuffer;
private void Awake()
{
rb = GetComponent<Rigidbody>();
}
private void Start()
{
InitializeWheels();
}
public void InitializeStateMachine()
{
SM = new StateMachine();
defaultState = new DefaultState(this, SM);
drivingState = new DrivingState(this, SM);
airState = new AirState(this, SM);
driftState = new DriftState(this, SM);
brakeState = new BrakeState(this, SM);
SM.InitializeState(defaultState);
}
public void InitializeInputs(IInputService _inputService)
{
playerInput = GetComponent<PlayerInput>();
inputService = _inputService;
}
private void InitializeWheels()
{
tires = new[] { Rear_L, Rear_R, Front_L, Front_R };
for (var i = 0; i < tireMeshes.Length; i++)
{
tireMeshes[i].transform.SetParent(tires[i]);
tireMeshes[i].transform.localPosition = Vector3.zero;
}
tireForces = new[] { Vector3.zero, Vector3.zero, Vector3.zero, Vector3.zero };
rearWheels = new Wheel(rearWheelData, rb, carData.topSpeed);
frontWheels = new Wheel(frontWheelData, rb, carData.topSpeed);
}
private void Update()
{
SM.currentState.LogicTick();
HandleTireRotation();
}
private void FixedUpdate()
{
SM.currentState.PhysicsTick();
}
public void PhysicsTick()
{
WheelTick();
for (var i = 0; i < tires.Length; i++)
rb.AddForceAtPosition(tireForces[i], tires[i].position);
if (IsGrounded())
{
_groundResetBuffer = 0;
}
else
{
_groundResetBuffer += Time.fixedDeltaTime;
if (_groundResetBuffer > 5f) ResetPosition();
}
}
[ContextMenu("ResetPosition")]
public void ResetPosition()
{
rb.position += Vector3.up;
rb.rotation = Quaternion.identity;
rb.linearVelocity = Vector3.zero;
rb.angularVelocity = Vector3.zero;
_groundResetBuffer = 0;
}
public void TryKeepUpright()
{
var avgY = transform.position.y;
for (var i = 0; i < tires.Length; i++)
{
var uprightForce = Vector3.up * ((avgY - tires[i].position.y) * carData.airAutoAdjust);
rb.AddForceAtPosition(uprightForce, tires[i].position);
}
}
private bool IsGrounded()
{
// Check ground distance using the rear wheel suspension settings
return Physics.Raycast(transform.position, -transform.up, out _, rearWheels.currentMaxSuspension);
}
private void WheelTick()
{
var grounded = false;
for (var i = 0; i < tires.Length; i++)
{
float actDist = (i < 2) ? rearWheels.currentMaxSuspension : frontWheels.currentMaxSuspension;
if (Physics.Raycast(tires[i].position, -tires[i].transform.up, out var hit, actDist))
{
grounded = true;
var tireLoc = hit.point + tires[i].transform.up * carData.wheelRadius;
tireMeshes[i].transform.position = Vector3.Lerp(tireMeshes[i].transform.position, tireLoc, carData.tireLerpAlpha);
tireForces[i] = (i < 2)
? rearWheels.CalculateWheelForces(hit, tireMeshes[i].transform, AccInput)
: frontWheels.CalculateWheelForces(hit, tireMeshes[i].transform, AccInput);
}
else
{
var tireLoc = tires[i].transform.position - (tires[i].transform.up * (actDist - carData.wheelRadius));
tireMeshes[i].transform.position = Vector3.Lerp(tireMeshes[i].transform.position, tireLoc, carData.tireLerpAlpha);
tireForces[i] = Vector3.zero;
}
}
if (!grounded && SM.currentState != airState) SM.ChangeState(airState);
else if (grounded && SM.currentState == airState) SM.ChangeState(defaultState);
}
private void HandleTireRotation()
{
var carSpeed = Vector3.Dot(transform.forward, rb.linearVelocity);
var angleAtSpeed = Mathf.Lerp(carData.maxTurnAngle, 0, carSpeed * carData.tireTurnDimFactor / carData.topSpeed);
var targetAngle = RotInput * angleAtSpeed;
m_CurrentSteerAngle = Mathf.MoveTowards(m_CurrentSteerAngle, targetAngle, Time.deltaTime * carData.tireTurnRate);
tireMeshes[2].transform.localRotation = Quaternion.Euler(0f, m_CurrentSteerAngle, 0f);
tireMeshes[3].transform.localRotation = Quaternion.Euler(0f, m_CurrentSteerAngle, 0f);
}
public void OnGameOver(int points)
{
SM.ChangeState(new GameOverState(this, SM));
inputService.DisableInputActions(playerInput.actions, SM.currentState);
playerInput.actions.Disable();
}
[ContextMenu("Refresh Tuning")]
private void SetWheels()
{
rearWheels.data = rearWheelData;
frontWheels.data = frontWheelData;
rearWheels.topSpeed = carData.topSpeed;
frontWheels.topSpeed = carData.topSpeed;
rearWheels.currentMaxSuspension = rearWheelData.maxSuspension;
rearWheels.currentRestSuspension = rearWheelData.restSuspension;
frontWheels.currentMaxSuspension = frontWheelData.maxSuspension;
frontWheels.currentRestSuspension = frontWheelData.restSuspension;
}
}