RaceStandings component that tracks live race order and time gaps. It records per-slot progress along the racing path and checkpoint crossing times, includes optional singleplayer ghost benchmarking, and produces ordered standings with gaps or lap-down indicators.
using Machines.Components;
using Machines.Ghost;
using Machines.Player;
using Machines.Race;
namespace Machines.GameModes;
/// <summary>
/// Live race standings: continuous position order with checkpoint-stepped time gaps.
/// </summary>
public sealed class RaceStandings : Component
{
public struct Standing
{
public Car Car; // may be invalid after cars are destroyed (podium)
public int Slot;
public string Name;
public Color Color;
public bool IsBot;
public bool IsLocal;
public ulong SteamId;
public int Position; // 1 = leader
public float GapToAhead; // seconds to the car ahead (or whole laps if LapsDown); -1 if unknown/leader
public bool LapsDown; // true when the gap is expressed in whole laps, not seconds
public bool IsGhost; // true if this entry represents the ghost car
public string GhostName; // display name for ghost entries
}
// Per-slot identity captured while cars are alive, so standings survive podium destruction.
private struct Entry
{
public Car Car;
public string Name;
public bool IsBot;
public bool IsLocal;
public ulong SteamId;
}
private RaceMode _race;
private readonly Dictionary<int, Entry> _roster = new();
private readonly Dictionary<int, float> _progress = new();
private readonly Dictionary<int, float> _lastRaw = new();
private float _ghostProgress;
private float _ghostLastRaw;
private bool _ghostTracking;
private readonly Dictionary<int, List<float>> _checkpointTimes = new();
private int _checkpointCount = -1;
private int CheckpointCount
{
get
{
if ( _checkpointCount < 0 )
_checkpointCount = Scene.GetAll<Checkpoint>().Count();
return _checkpointCount;
}
}
protected override void OnEnabled()
{
_race = GetComponent<RaceMode>();
}
protected override void OnUpdate()
{
if ( _race is null || _race.State != GameModeState.Playing )
return;
var path = RacingPath.Current;
if ( !path.IsValid() )
return;
var line = path.Optimal;
if ( line is null || !line.IsValid )
return;
var length = line.TotalLength;
if ( length < 0.001f )
return;
foreach ( var car in Scene.GetAllComponents<Car>() )
{
if ( !car.IsValid() || car.Slot < 0 )
continue;
var slot = car.Slot;
_roster[slot] = new Entry
{
Car = car,
Name = car.DisplayName,
IsBot = car.IsBot,
IsLocal = car.IsLocalPlayer,
SteamId = car.Network.Owner is { } owner ? owner.SteamId.ValueUnsigned : 0
};
var raw = line.GetDistanceAtPosition( car.WorldPosition );
if ( !_progress.TryGetValue( slot, out var prog ) )
{
var lap = Math.Max( 1, _race.GetPlayerState( slot ).CurrentLap );
prog = (lap - 1) * length + raw;
}
else
{
var delta = raw - _lastRaw[slot];
if ( delta < -length * 0.5f ) delta += length; // crossed the seam forward
else if ( delta > length * 0.5f ) delta -= length; // small backward wobble across the seam
prog += delta;
}
_progress[slot] = prog;
_lastRaw[slot] = raw;
}
// Track ghost progress (singleplayer only)
var ghost = GhostPlayer.Current;
if ( ghost.IsValid() && ghost.IsPlaying && IsSinglePlayer )
{
var ghostRaw = line.GetDistanceAtPosition( ghost.WorldPosition );
if ( !_ghostTracking )
{
_ghostProgress = ghostRaw;
_ghostTracking = true;
}
else
{
var delta = ghostRaw - _ghostLastRaw;
if ( delta < -length * 0.5f ) delta += length;
else if ( delta > length * 0.5f ) delta -= length;
_ghostProgress += delta;
}
_ghostLastRaw = ghostRaw;
}
else
{
_ghostTracking = false;
}
}
private bool IsSinglePlayer => Connection.All.Count <= 1;
/// <summary>
/// Reset all tracked progress and gap history.
/// </summary>
public void ResetStandings()
{
_roster.Clear();
_progress.Clear();
_lastRaw.Clear();
_checkpointTimes.Clear();
_checkpointCount = -1;
_ghostProgress = 0f;
_ghostLastRaw = 0f;
_ghostTracking = false;
}
/// <summary>
/// Record the time a car crossed a checkpoint (one call per valid in-order crossing).
/// </summary>
public void RecordCheckpoint( int slotIndex )
{
if ( !_checkpointTimes.TryGetValue( slotIndex, out var times ) )
{
times = new List<float>();
_checkpointTimes[slotIndex] = times;
}
times.Add( Time.Now );
}
/// <summary>
/// Total checkpoints crossed: (lap-1)*count + passed this lap. Used for lap-down gap detection only.
/// </summary>
private int PassedCount( int slotIndex )
{
var n = CheckpointCount;
if ( n <= 0 )
return 0;
var state = _race is null ? default : _race.GetPlayerState( slotIndex );
var lap = Math.Max( 1, state.CurrentLap );
return (lap - 1) * n + state.NextCheckpointIndex;
}
private float ContinuousProgress( int slotIndex )
{
return _progress.TryGetValue( slotIndex, out var p ) ? p : 0f;
}
/// <summary>
/// Race order with time gaps to the car ahead. Finished cars rank first; rest by continuous progress. Gaps update per checkpoint.
/// </summary>
public List<Standing> GetStandings()
{
PlayerRaceState State( int slot ) => _race is null ? default : _race.GetPlayerState( slot );
var ordered = _roster
.OrderByDescending( kv => State( kv.Key ).HasFinished )
.ThenBy( kv => State( kv.Key ).HasFinished ? State( kv.Key ).FinishTime : 0f )
.ThenByDescending( kv => ContinuousProgress( kv.Key ) )
.ToList();
// Build a combined list including the ghost if active in singleplayer
var ghost = GhostPlayer.Current;
var includeGhost = _ghostTracking && ghost.IsValid() && ghost.IsPlaying && IsSinglePlayer;
var result = new List<Standing>( ordered.Count + (includeGhost ? 1 : 0) );
// Ghost is a benchmark only - always rank it behind real cars.
int ghostInsertAt = includeGhost ? ordered.Count : -1;
int pos = 1;
for ( var i = 0; i < ordered.Count; i++ )
{
if ( includeGhost && i == ghostInsertAt )
{
result.Add( new Standing
{
Slot = -1,
Position = pos++,
GapToAhead = -1f,
LapsDown = false,
IsGhost = true,
GhostName = ghost.PlayerName ?? "Ghost"
} );
}
var (slot, entry) = ordered[i];
var standing = new Standing
{
Car = entry.Car,
Slot = slot,
Name = entry.Name,
Color = PlayerColors.GetColor( slot ),
IsBot = entry.IsBot,
IsLocal = entry.IsLocal,
SteamId = entry.SteamId,
Position = pos++,
GapToAhead = -1f,
LapsDown = false
};
if ( standing.Position > 1 && i > 0 )
standing.GapToAhead = ComputeGap( ordered[i - 1].Key, slot, out standing.LapsDown );
result.Add( standing );
}
// Ghost goes at the end
if ( includeGhost && ghostInsertAt >= ordered.Count )
{
result.Add( new Standing
{
Slot = -1,
Position = pos,
GapToAhead = -1f,
LapsDown = false,
IsGhost = true,
GhostName = ghost.PlayerName ?? "Ghost"
} );
}
return result;
}
/// <summary>
/// Gap to the car ahead at their last shared checkpoint. Returns whole laps if a lap or more behind.
/// </summary>
private float ComputeGap( int aheadSlot, int behindSlot, out bool lapsDown )
{
lapsDown = false;
var n = CheckpointCount;
var pAhead = PassedCount( aheadSlot );
var pBehind = PassedCount( behindSlot );
if ( n > 0 && pAhead - pBehind >= n )
{
lapsDown = true;
return (pAhead - pBehind) / n; // whole laps down
}
// Use trailing car's most recent checkpoint as reference.
if ( pBehind <= 0 )
return -1f;
if ( _checkpointTimes.TryGetValue( aheadSlot, out var aheadTimes ) && aheadTimes.Count >= pBehind &&
_checkpointTimes.TryGetValue( behindSlot, out var behindTimes ) && behindTimes.Count >= pBehind )
{
return MathF.Max( 0f, behindTimes[pBehind - 1] - aheadTimes[pBehind - 1] );
}
return -1f;
}
}