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.
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 ) );
}
}