Player/Car/AI/BotDriftController.cs

AI helper for bot cars that decides when to start, continue and stop drifting. It scans the racing line ahead for corners, evaluates thresholds (speed, curvature, heading), applies steer and drift input to CarInputData, and enforces cooldowns and timing.

Reflection
using Machines.Race;

namespace Machines.Player;

/// <summary>
/// Handles drift decisions for bots; simple but effective
/// </summary>
public sealed class BotDriftController
{
	private bool _isDrifting;
	private float _driftTimer;
	private int _driftDirection;
	private float _cooldown;

	/// <summary>
	/// Look-ahead time (s) when scanning for corners
	/// </summary>
	private const float ScanTimeAhead = 0.8f;

	/// <summary>
	/// Force drift on every detected corner regardless of arc length
	/// </summary>
	[ConVar( "bot_force_drift" )]
	public static bool ForceDrift { get; set; } = false;

	/// <summary>
	/// Updates drift decision for this frame, writing <see cref="CarInputData.Drift"/>
	/// </summary>
	public void Update( ref CarInputData data, float speed, RacingLine line, float pathDistance, bool canStart, Car car = null, float driftChance = 1f )
	{
		if ( _cooldown > 0f )
			_cooldown -= Time.Delta;

		if ( _isDrifting )
		{
			_driftTimer += Time.Delta;

			// Sample curvature ahead of the bot - exit early before the corner ends
			var lookAhead = MathF.Max( speed * 0.15f, 50f );
			var curvAhead = line?.GetCurvatureAtDistance( pathDistance + lookAhead ) ?? 0f;
			var curvNow = line?.GetCurvatureAtDistance( pathDistance ) ?? 0f;

			// Exit when the road ahead is straightening out
			var exitThreshold = 0.001f;
			var cornerEnding = _driftTimer > 0.25f && curvAhead < exitThreshold;

			// Exit if heading diverges too far from the racing line
			var headingBad = false;
			if ( car?.Movement != null && line != null && _driftTimer > 0.3f )
			{
				var carForward = car.Movement.GameObject.WorldRotation.Forward.WithZ( 0f ).Normal;
				var lineTangent = line.GetTangentAtDistance( pathDistance + lookAhead );
				var dot = Vector3.Dot( carForward, lineTangent );
				headingBad = dot < 0.6f;
			}

			var tooSlow = speed < 80f;
			var maxDuration = _driftTimer >= 1.5f;

			if ( cornerEnding || headingBad || tooSlow || maxDuration )
			{
				_isDrifting = false;
				_driftTimer = 0f;
				data.Drift = false;
				_cooldown = car?.Drift?.DriftCooldown ?? 0.5f;
			}
			else
			{
				data.Drift = true;
				var steerPush = MathF.Min( 0.3f, curvNow * 150f );
				data.Steer = (data.Steer + _driftDirection * steerPush).Clamp( -1f, 1f );
			}
		}
		else if ( canStart && _cooldown <= 0f && speed > 100f && line != null && car?.ActiveStats != null )
		{
			TryInitiateDrift( ref data, speed, line, pathDistance, car, driftChance );
		}
	}

	private void TryInitiateDrift( ref CarInputData data, float speed, RacingLine line, float pathDistance, Car car, float driftChance )
	{
		var stats = car.ActiveStats;

		var scanDist = speed * ScanTimeAhead;
		var step = RacingLine.CurvatureStep;
		var steps = (int)(scanDist / step);
		if ( steps < 2 ) return;

		// Force mode: near-zero catches any bend; normal mode: >15% steer input
		var steerForDrift = ForceDrift ? 0.02f : 0.15f;
		var driftThreshold = (steerForDrift * stats.TurnRate * MathF.PI / 180f) / MathF.Max( speed, 100f );

		// Find contiguous corner section
		float cornerStart = 0f, cornerEnd = 0f;
		bool inCorner = false;

		for ( int i = 1; i <= steps; i++ )
		{
			var d = pathDistance + i * step;
			var curv = line.GetCurvatureAtDistance( d );

			if ( curv >= driftThreshold )
			{
				if ( !inCorner )
				{
					inCorner = true;
					cornerStart = d;
				}
				cornerEnd = d;
			}
			else if ( inCorner )
			{
				break;
			}
		}

		if ( !inCorner ) return;

		var arcLength = cornerEnd - cornerStart;

		// Arc must be long enough to charge boost (skipped in force mode)
		if ( !ForceDrift )
		{
			var minArc = speed * stats.MinDriftTimeForBoost;
			if ( arcLength < minArc * 0.7f ) return;
		}

		// Determine corner direction from tangent change
		var mid = cornerStart + MathF.Max( arcLength * 0.5f, 10f );
		var t1 = line.GetTangentAtDistance( mid - 20f );
		var t2 = line.GetTangentAtDistance( mid + 20f );
		var direction = Vector3.Cross( t1, t2 ).z > 0f ? 1 : -1;

		// Wait until near corner entry
		var distToEntry = cornerStart - pathDistance;
		if ( distToEntry < 0f ) distToEntry += line.TotalLength;

		var entryLead = speed * 0.15f;
		if ( distToEntry > entryLead )
			return;

		// Skip based on driver's drift chance
		if ( !ForceDrift && Game.Random.Float() > driftChance )
		{
			_cooldown = 0.5f; // Avoid re-checking the same corner every frame
			return;
		}

		// Commit
		_isDrifting = true;
		_driftTimer = 0f;
		_driftDirection = direction;
		data.Drift = true;
		data.Steer = (data.Steer + _driftDirection * 0.3f).Clamp( -1f, 1f );
	}

	/// <summary>
	/// Clears drift state after a respawn/teleport
	/// </summary>
	public void Reset()
	{
		_isDrifting = false;
		_driftTimer = 0f;
		_cooldown = 0f;
		_driftDirection = 0;
	}
}