Player/Car/AI/RacingBrain.cs

AI driving brain for a bot car. It chooses racing line, handles states (Racing, Overtaking, Recovering, ReturningToTrack), senses obstacles via sphere casts, computes steering/throttle/boost/drift/item use and manages shortcuts and respawn fallbacks.

Native InteropNetworkingFile Access
using Machines.GameModes;
using Machines.Race;

namespace Machines.Player;

/// <summary>
/// Bot difficulty preset that <see cref="DriverPersonality"/> varies around
/// </summary>
public enum BotDifficulty
{
	Easy,
	Medium,
	Hard
}

/// <summary>
/// What the driver is currently doing
/// </summary>
public enum DriverState
{
	/// <summary>
	/// Following the racing line at the limit
	/// </summary>
	Racing,
	/// <summary>
	/// Racing while biasing laterally to pass another car
	/// </summary>
	Overtaking,
	/// <summary>
	/// Wedged or stalled, reversing to re-align with the line
	/// </summary>
	Recovering,
	/// <summary>
	/// Far off the line, driving back toward it under its own power
	/// </summary>
	ReturningToTrack
}

public sealed class RacingBrain : BotBrain
{
	[ConVar( "game_bot_difficulty", Saved = true, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
	public static BotDifficulty Difficulty { get; set; } = BotDifficulty.Hard;

	/// <summary>
	/// Max forward sweep distance at top speed
	/// </summary>
	[Property]
	public float SenseDistanceMax { get; set; } = 500f;

	/// <summary>
	/// Min look-ahead distance along the line (slow or cornering)
	/// </summary>
	[Property]
	public float LookAheadMin { get; set; } = 60f;

	/// <summary>
	/// Max look-ahead distance along the line (top speed on straights)
	/// </summary>
	[Property]
	public float LookAheadMax { get; set; } = 220f;

	/// <summary>
	/// Perp distance from the line beyond which the bot returns to track
	/// </summary>
	[Property]
	public float ReturnToTrackDistance { get; set; } = 280f;

	/// <summary>
	/// Extra distance ahead on the line to target when returning, for a shallower merge angle
	/// </summary>
	[Property]
	public float ReturnMergeAhead { get; set; } = 150f;

	/// <summary>
	/// Hard-respawn if stuck in a recovery state this long; 0 disables
	/// </summary>
	[Property]
	public float HardRespawnAfter { get; set; } = 6f;

	/// <summary>
	/// Hard-respawn if <see cref="MinProgressDistance"/> isn't made within this time; catches wheelspin/donut/pinned; 0 disables
	/// </summary>
	[Property]
	public float NoProgressRespawnAfter { get; set; } = 5f;

	/// <summary>
	/// Forward advance that resets the no-progress watchdog
	/// </summary>
	[Property]
	public float MinProgressDistance { get; set; } = 64f;

	/// <summary>
	/// Airborne longer than this is treated as a real fall and triggers return-to-track
	/// </summary>
	[Property]
	public float MaxAirborneTime { get; set; } = 1.5f;

	/// <summary>
	/// Scales curvature's pull on look-ahead toward <see cref="LookAheadMin"/> to prevent running wide in corners
	/// </summary>
	[Property]
	public float CornerLookAheadTighten { get; set; } = 600f;

	/// <summary>
	/// Distance within which a wall influences lateral offset or speed cap
	/// </summary>
	[Property]
	public float WallSafetyMargin { get; set; } = 90f;

	private DriverState _state = DriverState.Racing;
	private float _pathDistance;
	private TimeSince _timeSinceStart;
	private float _stuckTimer;
	private TimeSince _timeSinceReverse;
	private TimeUntil _recoveryGrace;
	private float _offTrackTimer;
	private TimeSince _timeSinceGrounded;
	private bool _wasGrounded = true;
	private bool _carBlocking;
	private float _passSide;
	private TimeSince _timeSinceRecover;
	private float _progressBaseline;   // _pathDistance at the last time we made real progress
	private TimeSince _timeSinceProgress;
	private float _returnStallTimer;
	private TimeUntil _returnReverse;
	private float _smoothSteer;
	private readonly BotDriftController _drift = new();
	private readonly BotBoost _boost = new();
	private readonly Items.BotItems _items = new();
	private TimeUntil _mistakeUntil;
	private float _mistakeSteer;

	// Shortcut state
	private ShortcutPath _activeShortcut;
	private RacingLine _mainLine; // stashed main line while on a shortcut
	private TimeUntil _nextShortcutCheck;
	private TimeSince _timeSinceShortcut;

	private RacingPath _path;
	private RacingLine _line;
	private DriverPersonality _personality;

	private float _probeRadius = -1f;
	private float _probeHeight;

	private Vector3 _debugTargetPos;

	private float CurrentSpeed => Car.Movement.IsValid() ? Car.Movement.CurrentSpeed : 0f;

	/// <summary>
	/// Braking decel, floored to prevent divide-by-zero
	/// </summary>
	private float BrakeDecel => MathF.Max( 50f, Car.ActiveStats.BrakeForce );

	// Personality resolution ranges, map 0-1 personality factors to actual physics values
	private const float MinSteerSharpness = 1.5f;
	private const float MaxSteerSharpness = 8.0f;
	private const float MinSteerSmoothing = 4f;
	private const float MaxSteerSmoothing = 30f;

	/// <summary>
	/// Personality MaxSpeed factor mapped to actual units/s
	/// </summary>
	private float PersonalityMaxSpeed => _personality.MaxSpeed * Car.ActiveStats.MaxSpeed;

	/// <summary>
	/// Personality LatAccel factor mapped to actual units/s/s
	/// </summary>
	private float PersonalityLatAccel => _personality.LatAccel * Car.ActiveStats.Acceleration;

	/// <summary>
	/// Personality SteerSharpness factor mapped to the steer exponent
	/// </summary>
	private float PersonalitySteerSharpness => MathX.Lerp( MinSteerSharpness, MaxSteerSharpness, _personality.SteerSharpness );

	/// <summary>
	/// Personality SteerSmoothing factor mapped to the lerp rate
	/// </summary>
	private float PersonalitySteerSmoothing => MathX.Lerp( MinSteerSmoothing, MaxSteerSmoothing, _personality.SteerSmoothing );

	protected override void OnStart()
	{
		_timeSinceStart = 0;
		EnsurePersonality();
		EnsureLine();
	}

	private void EnsurePersonality()
	{
		if ( _personality != null )
			return;

		var seed = Car.IsValid() && Car.Slot >= 0
			? Car.Slot
			: GameObject.Id.GetHashCode();

		_personality = DriverPersonality.Create( seed, Difficulty );
	}

	/// <summary>
	/// Acquires the optimal racing line; safe every frame, handles late <see cref="RacingPath"/> baking
	/// </summary>
	private bool EnsureLine()
	{
		if ( _line != null && _line.IsValid )
			return true;

		_path = RacingPath.Current;
		if ( _path == null || _path.Optimal == null || !_path.Optimal.IsValid )
			return false;

		_line = _path.Optimal;
		_pathDistance = _line.GetDistanceAtPosition( WorldPosition );
		return true;
	}

	public override CarInputData Tick()
	{
		// Car can be mid-destruction (podium cleanup, kicked bot) while we still tick this frame
		if ( !Car.IsValid() )
			return default;

		EnsurePersonality();

		if ( !EnsureLine() )
			return new CarInputData { Throttle = 1f };

		AdvancePathDistance();
		TickShortcutRoute();

		// Track airtime to distinguish ramp jumps from real falls; resync on touchdown
		const float landingGrace = 0.8f;
		var grounded = !Car.Movement.IsValid() || Car.Movement.IsGrounded;
		if ( grounded )
			_timeSinceGrounded = 0;
		if ( grounded && !_wasGrounded )
		{
			ReprojectPath();
			if ( _recoveryGrace < landingGrace )
				_recoveryGrace = landingGrace;
		}
		_wasGrounded = grounded;

		var absSpeed = MathF.Abs( CurrentSpeed );
		var expected = _line.GetPointAtDistance( _pathDistance );
		var perpDist = (WorldPosition - expected).WithZ( 0f ).Length;

		UpdateState( absSpeed, perpDist );

		// Last-resort: hard-teleport if stuck out of racing states too long
		if ( TickHardRespawnFallback() )
			return new CarInputData { Throttle = 1f };

		if ( TickNoProgressWatchdog() )
			return new CarInputData { Throttle = 1f };

		var data = _state switch
		{
			DriverState.Recovering => TickRecovering(),
			DriverState.ReturningToTrack => TickReturningToTrack(),
			_ => TickRacing( absSpeed )
		};

		ApplyMistakes( ref data );
		return data;
	}

	/// <summary>
	/// Advances path distance by velocity dot tangent (forward progress only), wraps at seam
	/// </summary>
	private void AdvancePathDistance()
	{
		if ( _activeShortcut != null )
		{
			// Shortcuts are open paths; use position projection since velocity-dot-tangent is too slow at entry angles
			var projected = _line.GetDistanceAtPosition( WorldPosition, _pathDistance, 400f );
			if ( projected > _pathDistance )
				_pathDistance = projected;

			_pathDistance = _pathDistance.Clamp( 0f, _line.TotalLength );
		}
		else
		{
			var tangent = _line.GetTangentAtDistance( _pathDistance );
			if ( Car.Movement.IsValid() )
			{
				var progress = Vector3.Dot( Car.Movement.Velocity, tangent );
				if ( progress > 0f )
					_pathDistance += progress * Time.Delta;
			}

			_pathDistance %= _line.TotalLength;
			if ( _pathDistance < 0f )
				_pathDistance += _line.TotalLength;
		}
	}

	/// <summary>
	/// Re-syncs path distance via windowed projection to avoid snapping to the wrong side of a looping track
	/// </summary>
	private void ReprojectPath()
	{
		if ( _activeShortcut != null )
		{
			// On a shortcut, forward-only reprojection to avoid snapping backward
			var projected = _line.GetDistanceAtPosition( WorldPosition, _pathDistance, 400f );
			if ( projected > _pathDistance )
				_pathDistance = projected;
		}
		else
		{
			_pathDistance = _line.GetDistanceAtPosition( WorldPosition, _pathDistance, 1000f );
		}
	}

	public override void OnRespawned()
	{
		// Full unwindowed reproject after teleport; clear transient state
		if ( !EnsureLine() )
			return;

		_pathDistance = _line.GetDistanceAtPosition( WorldPosition );
		_progressBaseline = _pathDistance;
		_timeSinceProgress = 0;
		_state = DriverState.Racing;
		_stuckTimer = 0f;
		_offTrackTimer = 0f;
		_timeSinceRecover = 0;
		_timeSinceReverse = 0;
		_returnStallTimer = 0f;
		_returnReverse = 0f;
		_recoveryGrace = 3f;
		_timeSinceGrounded = 0;
		_wasGrounded = true;
		_carBlocking = false;
		_passSide = 0f;
	}

	private void UpdateState( float absSpeed, float perpDist )
	{
		var grounded = !Car.Movement.IsValid() || Car.Movement.IsGrounded;

		// Stuck = crawling while racing; airborne and car-blocked don't count
		var racing = _state == DriverState.Racing || _state == DriverState.Overtaking;
		var stuckCandidate = racing && grounded && !_carBlocking && _timeSinceStart > 2f && _recoveryGrace <= 0f && absSpeed < 30f;
		if ( stuckCandidate )
			_stuckTimer += Time.Delta;
		else if ( racing )
			_stuckTimer = MathF.Max( 0f, _stuckTimer - Time.Delta * 3f );
		else
			_stuckTimer = 0f;

		// Off-track = too far from line or real fall; debounced, suppressed during shortcut entry
		var shortcutGrace = _activeShortcut != null && _timeSinceShortcut < 2f;
		var offTrack = !shortcutGrace && (perpDist > ReturnToTrackDistance || _timeSinceGrounded > MaxAirborneTime);
		_offTrackTimer = offTrack
			? _offTrackTimer + Time.Delta
			: MathF.Max( 0f, _offTrackTimer - Time.Delta * 2f );

		// Recovering exits from within TickRecovering
		if ( _state == DriverState.Recovering )
			return;

		if ( _stuckTimer > 0.8f )
		{
			_state = DriverState.Recovering;
			_timeSinceReverse = 0;
			_stuckTimer = 0f;
			return;
		}

		if ( _state == DriverState.ReturningToTrack )
		{
			if ( perpDist < ReturnToTrackDistance * 0.5f && grounded )
			{
				_state = DriverState.Racing;
				ReprojectPath();
			}
			return;
		}

		if ( _offTrackTimer > 0.4f )
			EnterReturningToTrack();
	}

	/// <summary>
	/// Hard-respawns if in recovery longer than <see cref="HardRespawnAfter"/>; returns true if triggered
	/// </summary>
	private bool TickHardRespawnFallback()
	{
		var recovering = _state == DriverState.Recovering || _state == DriverState.ReturningToTrack;
		if ( !recovering || HardRespawnAfter <= 0f )
		{
			_timeSinceRecover = 0;
			return false;
		}

		if ( _timeSinceRecover < HardRespawnAfter )
			return false;

		_timeSinceRecover = 0;

		if ( !Car.Respawn.IsValid() )
			return false;

		// Respawn() self-gates and debounces; OnRespawned() resets state
		Car.Respawn.Respawn();
		return true;
	}

	/// <summary>
	/// Hard-respawns if racing but not making forward progress; catches wheelspin/donut/pinned cases
	/// </summary>
	private bool TickNoProgressWatchdog()
	{
		// Only watch during active play; bots idle during countdown by design
		var playing = BaseGameMode.Current.IsValid() && BaseGameMode.Current.State == GameModeState.Playing;

		// Only racing states; Recovering/ReturningToTrack have their own fallback; airborne and grace exempt
		var grounded = !Car.Movement.IsValid() || Car.Movement.IsGrounded;
		var racing = _state == DriverState.Racing || _state == DriverState.Overtaking;
		var watching = playing && racing && grounded && _timeSinceStart > 2f && _recoveryGrace <= 0f;

		if ( !watching || NoProgressRespawnAfter <= 0f )
		{
			_timeSinceProgress = 0;
			_progressBaseline = _pathDistance;
			return false;
		}

		// Forward progress since baseline, wrap-corrected
		var advanced = _pathDistance - _progressBaseline;
		if ( advanced < -_line.TotalLength * 0.5f )
			advanced += _line.TotalLength;

		if ( advanced >= MinProgressDistance )
		{
			_progressBaseline = _pathDistance;
			_timeSinceProgress = 0;
			return false;
		}

		if ( _timeSinceProgress < NoProgressRespawnAfter )
			return false;

		_timeSinceProgress = 0;
		if ( !Car.Respawn.IsValid() )
			return false;

		// Respawn() self-gates; OnRespawned() resets state including _progressBaseline
		Car.Respawn.Respawn();
		return true;
	}

	private void EnterReturningToTrack()
	{
		_state = DriverState.ReturningToTrack;
		_returnStallTimer = 0f;
		_returnReverse = 0f;

		// Full unwindowed search so merge target is current position, not a stale distance
		_pathDistance = _line.GetDistanceAtPosition( WorldPosition );
	}

	private CarInputData TickRacing( float absSpeed )
	{
		var data = new CarInputData();

		// Forward probe reaches at least braking distance; side probes need modest reach
		var brakeDist = (absSpeed * absSpeed) / (2f * BrakeDecel);
		var forwardRange = (brakeDist + 120f).Clamp( 150f, SenseDistanceMax );
		var sideRange = (absSpeed * 0.7f + 80f).Clamp( 120f, SenseDistanceMax );
		var scan = ScanObstacles( forwardRange, sideRange );

		// Car blocking = close ahead; suppresses stuck-reverse so we overtake/follow instead
		_carBlocking = scan.FrontCar.IsValid() && scan.FrontDistance < CarBlockDistance;

		var lookAhead = MathX.Lerp( LookAheadMin, LookAheadMax, absSpeed / MathF.Max( 1f, PersonalityMaxSpeed ) )
			* _personality.LookAheadScale;

		// Tighten look-ahead in corners to avoid understeer/running wide
		var curve = _line.GetCurvatureAtDistance( _pathDistance + lookAhead * 0.5f );
		var cornerT = MathF.Min( 1f, curve * CornerLookAheadTighten );
		lookAhead = MathX.Lerp( lookAhead, LookAheadMin, cornerT );

		var aheadDist = _pathDistance + lookAhead;

		// Single lateral offset; avoidance and line preference share it, clamped to drivable width
		var offset = ComputeLateralOffset( aheadDist, scan, out var overtaking, out var clearToPass );
		_state = overtaking ? DriverState.Overtaking : DriverState.Racing;

		var basePoint = _line.GetPointAtDistance( aheadDist );
		var tangent = _line.GetTangentAtDistance( aheadDist );
		var perp = Vector3.Cross( tangent, Vector3.Up ).Normal; // positive = right of travel
		var targetPos = basePoint + perp * offset;
		_debugTargetPos = targetPos;

		// 1. Curvature feedforward, pre-steer for the upcoming curve. Sampled well ahead so the car turns
			// in early and holds the apex instead of washing wide (steer input is smoothed twice, here and
			// in CarMovement, so late pre-steer reads as understeer).
		var feedforwardDist = _pathDistance + lookAhead * 0.6f;
		var feedforwardCurvature = _line.GetCurvatureAtDistance( feedforwardDist );
		var feedforwardTangent = _line.GetTangentAtDistance( feedforwardDist );

		// Curvature sign (left vs right) from the tangent cross with the curve direction
		var curvatureSign = MathF.Sign( Vector3.Cross( feedforwardTangent,
			_line.GetTangentAtDistance( feedforwardDist + 30f ) ).z );

		var stats = Car.ActiveStats;
		var feedforward = BotSteering.CurvatureFeedforward(
			feedforwardCurvature, absSpeed, stats.TurnRate, stats.MaxSpeed ) * curvatureSign;

		// 2. Reactive correction toward the look-ahead target, damped by heading alignment
		var carForward = WorldRotation.Forward.WithZ( 0f ).Normal;
		var trackTangent = _line.GetTangentAtDistance( _pathDistance );
		var alignment = MathF.Max( 0f, Vector3.Dot( carForward, trackTangent ) ); // 0=perpendicular, 1=aligned

		// Dampen reactive only on straights; corners need full power to cover feedforward error
		var onStraight = feedforwardCurvature < 0.001f ? 1f : 0f;
		var reactiveScale = MathX.Lerp( 1f, 0.6f, alignment * alignment * onStraight );
		var rawReactive = RawSteerToward( targetPos );
		var reactive = rawReactive * reactiveScale;

		// 3. Blend, feedforward provides the baseline, reactive corrects errors
		var combinedSteer = (feedforward + reactive).Clamp( -1f, 1f );

		// Faster smoothing for large corrections to avoid understeer
		var steerError = MathF.Abs( combinedSteer - _smoothSteer );
		var smoothRate = PersonalitySteerSmoothing * (1f + steerError * 2f);
		_smoothSteer = MathX.Lerp( _smoothSteer, combinedSteer, Time.Delta * smoothRate );
		data.Steer = _smoothSteer;

		// Physics-based corner braking, brake early for the most limiting upcoming corner
		var targetSpeed = GetTargetSpeed( absSpeed );

		// Obstacle braking; skip if we're committed to passing the car ahead
		if ( scan.HasFront && !(scan.FrontCar.IsValid() && clearToPass) )
			targetSpeed = MathF.Min( targetSpeed, FrontObstacleSpeedCap( scan ) );

		data.Throttle = ThrottleForSpeed( absSpeed, targetSpeed );

		// Only drift when approaching a corner, not braking for an obstacle
		var canDrift = EnableDrift && absSpeed > 100f;
		_drift.Update( ref data, absSpeed, _line, _pathDistance, canStart: canDrift, car: Car, driftChance: _personality.DriftChance );

		// Min throttle floor on straights only, not when cornering
		if ( data.Throttle > 0f && targetSpeed > absSpeed * 1.1f )
			data.Throttle = MathF.Max( data.Throttle, 0.3f );

		// Sprint on clear straights or ram nearby cars based on aggression
		if ( EnableBoost )
		{
			var boostScan = new ObstacleScanResult
			{
				HasFrontCar = scan.FrontCar.IsValid(),
				FrontDistance = scan.FrontDistance,
				LeftClearance = scan.LeftClearance,
				RightClearance = scan.RightClearance
			};
			var canSprint = data.Throttle > 0f && (!scan.HasFront || clearToPass);
			_boost.Apply( ref data, Car.Boost, boostScan, _personality.Aggression, canSprint );
		}

		// Use held item when situation fits
		_items.Apply( ref data, Car.Inventory, scan.FrontCar.IsValid(), scan.FrontDistance );

		return data;
	}

	/// <summary>
	/// Gap the bot keeps to a car ahead when following
	/// </summary>
	private const float FollowBuffer = 90f;

	/// <summary>
	/// Max speed from which we can brake to every upcoming corner's safe speed; friction scales braking
	/// </summary>
	private float GetTargetSpeed( float absSpeed )
	{
		var maxSpeed = PersonalityMaxSpeed;
		var brakeDecel = BrakeDecel;

		// Push past the theoretical grip limit so bots carry real corner speed instead of crawling
		var latAccel = PersonalityLatAccel * 1.6f;

		var hereHint = _line.GetHintAtDistance( _pathDistance );
		var hereFriction = MathF.Max( 0.1f, hereHint.Friction );
		var best = MathF.Min( maxSpeed, _line.GetSafeSpeedAtDistance( _pathDistance, latAccel * hereFriction ) );

		var horizon = (maxSpeed * maxSpeed) / (2f * brakeDecel) + 150f;
		var step = RacingLine.CurvatureStep;
		for ( var d = step; d <= horizon; d += step )
		{
			var hint = _line.GetHintAtDistance( _pathDistance + d );
			var friction = MathF.Max( 0.1f, hint.Friction );
			var effectiveLatAccel = latAccel * friction;

			var safe = _line.GetSafeSpeedAtDistance( _pathDistance + d, effectiveLatAccel );
			if ( safe >= maxSpeed )
				continue;

			var allowed = MathF.Sqrt( safe * safe + 2f * brakeDecel * d );
			if ( allowed < best )
				best = allowed;
		}

		return best.Clamp( 0f, maxSpeed );
	}

	/// <summary>
	/// Speed cap from the obstacle dead ahead: match a leading car's speed or slow near a wall
	/// </summary>
	private float FrontObstacleSpeedCap( ObstacleScan scan )
	{
		if ( scan.FrontCar.IsValid() )
		{
			var otherSpeed = scan.FrontCar.Movement.IsValid()
				? MathF.Abs( scan.FrontCar.Movement.CurrentSpeed )
				: 0f;

			var gap = scan.FrontDistance - FollowBuffer;
			if ( gap <= 0f )
				return MathF.Min( otherSpeed, PersonalityMaxSpeed );

			return MathF.Sqrt( otherSpeed * otherSpeed + 2f * BrakeDecel * gap );
		}

		var brakeZone = WallSafetyMargin * 2f;
		if ( scan.FrontDistance >= brakeZone )
			return PersonalityMaxSpeed;

		return MathX.Lerp( 60f, PersonalityMaxSpeed, (scan.FrontDistance / brakeZone).Clamp( 0f, 1f ) );
	}

	private static float ThrottleForSpeed( float absSpeed, float targetSpeed )
	{
		if ( absSpeed > targetSpeed )
			return -1f;

		// Approaching target, ease off to avoid overshoot
		if ( absSpeed > targetSpeed * 0.95f )
		{
			var t = (absSpeed - targetSpeed * 0.95f) / (targetSpeed * 0.05f + 1f);
			return MathX.Lerp( 1f, 0f, t.Clamp( 0f, 1f ) );
		}

		return 1f;
	}

	/// <summary>
	/// Min lateral room required to commit to a pass
	/// </summary>
	private const float MinPassOffset = 60f;

	/// <summary>
	/// Distance within which a car ahead is counted as blocking (overtake or follow, never reverse)
	/// </summary>
	private const float CarBlockDistance = 180f;

	private float ComputeLateralOffset( float aheadDist, ObstacleScan scan, out bool overtaking, out bool clearToPass )
	{
		overtaking = false;
		clearToPass = false;
		var offset = 0f;
		var side = scan.RightClearance >= scan.LeftClearance ? 1f : -1f; // toward the clearer side

		float rightRoom, leftRoom;
		if ( _path.IsValid() )
			_path.GetSideRoomAtDistance( aheadDist, out rightRoom, out leftRoom );
		else
			rightRoom = leftRoom = 80f;
		var rightLimit = MathF.Max( 0f, rightRoom * 0.9f );
		var leftLimit = MathF.Max( 0f, leftRoom * 0.9f );

		// Clear sticky pass side when no car is ahead
		if ( !(scan.HasFront && scan.FrontCar.IsValid()) )
			_passSide = 0f;

		if ( scan.HasFront )
		{
			if ( scan.FrontCar.IsValid() )
			{
				overtaking = true;

				// Pick the side with more room; hold sticky (side sweeps collapse at low speed)
				if ( _passSide == 0f )
					_passSide = rightLimit >= leftLimit ? 1f : -1f;

				var sideLimit = _passSide > 0f ? rightLimit : leftLimit;
				var passOffset = MathF.Min( sideLimit, MathX.Lerp( 110f, 200f, _personality.Aggression ) );
				clearToPass = passOffset >= MinPassOffset;

				if ( clearToPass )
				{
					offset += _passSide * passOffset;
				}
				else
				{
					// Boxed in, nudge toward clearer side but follow or brake
					var proximity = 1f - (scan.FrontDistance / scan.Range);
					offset += side * proximity * MathX.Lerp( 70f, 180f, _personality.Aggression );
				}
			}
			else
			{
				// Wall ahead: swerve only when close, distant walls are the line's job
				var close = MathF.Max( 0f, 1f - scan.FrontDistance / WallSafetyMargin );
				offset += side * close * 200f;
			}
		}

		// Alongside-wall nudge, only inside safety margin to avoid shoving normal cornering wide
		var leftPush = MathF.Max( 0f, 1f - scan.LeftClearance / WallSafetyMargin );
		var rightPush = MathF.Max( 0f, 1f - scan.RightClearance / WallSafetyMargin );
		offset += (leftPush - rightPush) * 130f;

		// Default to driver's lateral bias when uncontested
		if ( MathF.Abs( offset ) < 1f && !overtaking )
			offset += _personality.LateralBias * 40f;

		// Clamp asymmetrically to available room each side
		return offset.Clamp( -leftLimit, rightLimit );
	}

	private CarInputData TickReturningToTrack()
	{
		_pathDistance = _line.GetDistanceAtPosition( WorldPosition );

		var target = _line.GetPointAtDistance( _pathDistance + ReturnMergeAhead );
		_debugTargetPos = target;

		var data = new CarInputData();
		var forward = WorldRotation.Forward;
		var dirToTarget = (target - WorldPosition).WithZ( 0f ).Normal;
		var dot = Vector3.Dot( forward, dirToTarget );
		var steer = RawSteerToward( target );
		var absSpeed = MathF.Abs( CurrentSpeed );

		// Mid reverse-jiggle: back away briefly, inverted steer swings nose toward target
		if ( _returnReverse > 0f )
		{
			data.Throttle = -0.8f;
			data.Steer = -steer;
			return data;
		}

		if ( dot < -0.1f )
		{
			// Facing away; reverse-turn to swing nose around
			data.Throttle = -0.7f;
			data.Steer = -steer;
			return data;
		}

		// Forward toward merge point; steer around close walls; reverse-jiggle if stalled
		var scan = ScanObstacles( 130f, 130f );
		if ( scan.HasFront && !scan.FrontCar.IsValid() && scan.FrontDistance < WallSafetyMargin )
		{
			var side = scan.RightClearance >= scan.LeftClearance ? 1f : -1f;
			steer = (steer + side * 0.8f).Clamp( -1f, 1f );
		}

		if ( absSpeed < 25f && _timeSinceStart > 2f )
		{
			_returnStallTimer += Time.Delta;
			if ( _returnStallTimer > 0.6f )
			{
				_returnReverse = 0.7f;
				_returnStallTimer = 0f;
			}
		}
		else
		{
			_returnStallTimer = MathF.Max( 0f, _returnStallTimer - Time.Delta * 2f );
		}

		// Ease off while turning hard toward the line; full power once aligned
		data.Throttle = MathX.Lerp( 0.45f, 0.85f, dot.Clamp( 0f, 1f ) );
		data.Steer = steer;
		return data;
	}

	private CarInputData TickRecovering()
	{
		var data = new CarInputData { Throttle = -1f };

		var target = _line.GetPointAtDistance( _pathDistance + 40f );
		_debugTargetPos = target;
		data.Steer = -RawSteerToward( target ); // inverted while reversing

		var absSpeed = MathF.Abs( CurrentSpeed );
		var facing = Vector3.Dot( WorldRotation.Forward, _line.GetTangentAtDistance( _pathDistance ) );
		var reversedEnough = _timeSinceReverse > 0.4f && absSpeed > 20f && facing > 0.5f;

		if ( _timeSinceReverse > 1.5f || reversedEnough )
		{
			_timeSinceReverse = 0;
			_stuckTimer = 0f;
			_recoveryGrace = 3f;
			ReprojectPath();

			// Still off-line: go to ReturningToTrack, not Racing, to avoid gunning into the obstacle
			var expected = _line.GetPointAtDistance( _pathDistance );
			var perpDist = (WorldPosition - expected).WithZ( 0f ).Length;
			if ( perpDist > ReturnToTrackDistance * 0.5f )
				EnterReturningToTrack();
			else
				_state = DriverState.Racing;
		}

		return data;
	}

	private float RawSteerToward( Vector3 targetPos )
	{
		var toTarget = (targetPos - WorldPosition).WithZ( 0f ).Normal;
		return BotSteering.RawSteer( WorldRotation.Forward, toTarget, PersonalitySteerSharpness );
	}

	private void ApplyMistakes( ref CarInputData data )
	{
		if ( _state == DriverState.Recovering || _state == DriverState.ReturningToTrack )
			return;

		if ( _mistakeUntil > 0f )
		{
			data.Steer = (data.Steer + _mistakeSteer).Clamp( -1f, 1f );
		}
		else if ( Game.Random.Float() < _personality.MistakeRate * Time.Delta )
		{
			_mistakeUntil = Game.Random.Float( 0.2f, 0.5f );
			_mistakeSteer = Game.Random.Float( -0.35f, 0.35f );
		}
	}

	/// <summary>
	/// Manages active route: checks shortcut completion or evaluates upcoming shortcuts each frame
	/// </summary>
	private void TickShortcutRoute()
	{
		// Currently on a shortcut, check if we've reached the end
		if ( _activeShortcut != null )
		{
			// Open path; completion = near end distance
			if ( _pathDistance >= _activeShortcut.Line.TotalLength * 0.9f )
			{
				// Rejoin the main racing line
				_line = _mainLine;
				_pathDistance = _activeShortcut.ExitDistance;
				_activeShortcut = null;
				_mainLine = null;
				return;
			}

			// Bail if stuck on or knocked off the shortcut
			if ( _timeSinceShortcut > 10f )
			{
				_line = _mainLine;
				_pathDistance = _line.GetDistanceAtPosition( WorldPosition );
				_activeShortcut = null;
				_mainLine = null;
			}
			return;
		}

		// Poll often to avoid overshooting the entry window
		if ( _nextShortcutCheck > 0f )
			return;
		_nextShortcutCheck = 0.1f;

		if ( _path == null || _path.Shortcuts.Count == 0 )
			return;

		var fwd = WorldRotation.Forward.WithZ( 0f ).Normal;

		foreach ( var shortcut in _path.Shortcuts )
		{
			if ( shortcut.Line == null || !shortcut.Line.IsValid )
				continue;

			// Difficulty gate
			if ( Difficulty < shortcut.MinDifficulty )
				continue;

			// Entry must be within 500 units ahead on the racing line
			var distToEntry = shortcut.EntryDistance - _pathDistance;
			if ( distToEntry < 0f )
				distToEntry += _line.TotalLength;

			if ( distToEntry > 500f || distToEntry < 10f )
				continue;

			// First point must be roughly ahead of us
			var toEntry = (shortcut.Points[0] - WorldPosition).WithZ( 0f );
			if ( toEntry.Length > 30f && Vector3.Dot( fwd, toEntry.Normal ) < 0f )
				continue;

			// Chance = aggression * (1-risk), boosted by proximity to avoid missing the window
			var proximityBoost = 1f + (1f - distToEntry / 500f);
			var chance = _personality.Aggression * (1f - shortcut.RiskFactor) * proximityBoost * 0.1f;
			if ( Game.Random.Float() > chance )
				continue;

			// Commit to the shortcut's pre-built RacingLine
			_mainLine = _line;
			_activeShortcut = shortcut;
			_line = shortcut.Line;
			// Start at distance 0; we're approaching the entry
			_pathDistance = 0f;
			_timeSinceShortcut = 0;
			break;
		}
	}

	/// <summary>
	/// Forward sweep result: front hit distance, whether it's a car, and side clearances
	/// </summary>
	private struct ObstacleScan
	{
		public bool HasFront;
		public float FrontDistance;
		public Car FrontCar;
		public float LeftClearance;
		public float RightClearance;
		public float Range;
	}

	/// <summary>
	/// Fan of sphere casts (center, +-25 deg, +-55 deg); forward probe at <paramref name="forwardRange"/>, sides at <paramref name="sideRange"/>
	/// </summary>
	private ObstacleScan ScanObstacles( float forwardRange, float sideRange )
	{
		var scan = new ObstacleScan
		{
			Range = forwardRange,
			FrontDistance = forwardRange,
			LeftClearance = sideRange,
			RightClearance = sideRange
		};

		var forward = WorldRotation.Forward;
		var right = WorldRotation.Right;

		if ( SphereCast( forward, forwardRange, out var fd, out var car ) )
		{
			scan.HasFront = true;
			scan.FrontDistance = fd;
			scan.FrontCar = car;
		}

		// Narrow fan reads ahead, wide short probes catch a corner wall beside the nose
		var wide = MathF.Min( sideRange, 140f );
		scan.LeftClearance = MathF.Min(
			SideClearance( forward, right, -25f, sideRange ),
			SideClearance( forward, right, -55f, wide ) );
		scan.RightClearance = MathF.Min(
			SideClearance( forward, right, 25f, sideRange ),
			SideClearance( forward, right, 55f, wide ) );

		return scan;
	}

	/// <summary>
	/// Sphere-cast at <paramref name="angleDeg"/> off the nose; returns hit distance or <paramref name="range"/> if clear
	/// </summary>
	private float SideClearance( Vector3 forward, Vector3 right, float angleDeg, float range )
	{
		var ang = MathX.DegreeToRadian( angleDeg );
		var dir = (forward * MathF.Cos( ang ) + right * MathF.Sin( ang )).Normal;
		return SphereCast( dir, range, out var d, out _ ) ? d : range;
	}

	/// <summary>
	/// Flat sphere cast from mid-height, ignoring self and walkable floor/ramp hits
	/// </summary>
	private bool SphereCast( Vector3 direction, float distance, out float hitDistance, out Car car )
	{
		hitDistance = distance;
		car = null;

		EnsureProbeSize();

		var flatDir = direction.WithZ( 0f ).Normal;
		var origin = WorldPosition + Vector3.Up * _probeHeight;

		var tr = Scene.Trace
			.Sphere( _probeRadius, origin, origin + flatDir * distance )
			.IgnoreGameObjectHierarchy( Car.GameObject )
			.Run();

		if ( !tr.Hit )
			return false;

		// Ignore walkable surfaces using the car's own climb limit
		var walkableZ = Car.Movement.IsValid() ? Car.Movement.WalkableZ : 0.5f;
		if ( tr.Normal.z >= walkableZ )
			return false;

		hitDistance = tr.Distance;
		if ( tr.GameObject.IsValid() )
			car = tr.GameObject.GetComponentInParent<Car>();
		return true;
	}

	private void EnsureProbeSize()
	{
		if ( _probeRadius >= 0f )
			return;

		var b = Car.GameObject.GetBounds();
		_probeHeight = MathF.Max( 10f, b.Size.z * 0.5f );
		_probeRadius = (b.Size.y * 0.5f).Clamp( 16f, 40f );
	}

	protected override void DrawGizmos()
	{
		Gizmo.Transform = global::Transform.Zero;

		var pos = WorldPosition;

		Gizmo.Draw.Color = _state switch
		{
			DriverState.Recovering => Color.Red,
			DriverState.ReturningToTrack => Color.Orange,
			DriverState.Overtaking => Color.Magenta,
			_ => Color.Yellow
		};
		Gizmo.Draw.Line( pos, _debugTargetPos );

		if ( _line != null && _line.IsValid )
		{
			Gizmo.Draw.Color = Color.Cyan;
			var prev = pos;
			for ( int i = 0; i < 20; i++ )
			{
				var next = _line.GetPointAtDistance( _pathDistance + i * 20f );
				Gizmo.Draw.Line( prev, next );
				prev = next;
			}
		}

		Gizmo.Draw.Color = Color.White;
		Gizmo.Draw.Text( $"{_state}  d{_pathDistance:F0}", new global::Transform( _debugTargetPos ) );
	}
}