RaceMode gamemode for a lap-based race. Tracks per-player PlayerRaceState (checkpoints, laps, times), handles checkpoint hits, lap completion, ghost recording/spawning, standings, finish countdown and state transitions.
using Machines.Components;
using Machines.Events;
using Machines.Ghost;
using Machines.Player;
using Machines.Race;
using Machines.Systems;
namespace Machines.GameModes;
/// <summary>
/// Per-player race state tracked by the gamemode.
/// </summary>
public struct PlayerRaceState
{
public int NextCheckpointIndex;
public int CurrentLap;
public float LapStartTime;
public float LastLapTime;
public float BestLapTime;
public bool HasFinished;
public float FinishTime;
public static PlayerRaceState Create()
{
return new PlayerRaceState
{
NextCheckpointIndex = 0,
CurrentLap = 1,
LapStartTime = Time.Now,
LastLapTime = 0f,
BestLapTime = float.MaxValue,
HasFinished = false,
FinishTime = 0f
};
}
}
/// <summary>
/// Lap-based racing mode: checkpoint progression, ghost spawning, per-player race state.
/// </summary>
public sealed class RaceMode : BaseGameMode, ICheckpointListener
{
/// <summary>
/// Number of laps to complete the race (min 1).
/// </summary>
[ConVar( "game_total_laps", Saved = true, Min = 1, Max = 6, Flags = ConVarFlags.Replicated | ConVarFlags.GameSetting )]
public static int TotalLaps { get; set; } = 4;
/// <summary>
/// Seconds remaining for the field after the first finisher crosses the line.
/// </summary>
[Property]
public float FinishCountdownDuration { get; set; } = 60f;
/// <summary>
/// True once the first-finisher countdown has begun.
/// </summary>
[Sync]
public bool FinishCountdownActive { get; set; }
/// <summary>
/// When the post-finish countdown expires. Valid only while <see cref="FinishCountdownActive"/> is true.
/// </summary>
[Sync]
public TimeUntil FinishCountdownEndsAt { get; set; }
/// <summary>
/// Seconds left on the post-finish countdown (0 when inactive or expired).
/// </summary>
public float FinishCountdownRemaining => FinishCountdownActive ? MathF.Max( 0f, FinishCountdownEndsAt ) : 0f;
/// <summary>
/// Slot of the player that finished first. -1 = no winner yet.
/// </summary>
[Sync]
public int WinnerSlot { get; set; } = -1;
/// <summary>
/// Display name of the first finisher (persisted because cars are destroyed at podium).
/// </summary>
[Sync]
public string WinnerName { get; set; } = "";
/// <summary>
/// Race start time for the timer display.
/// </summary>
[Sync]
public float RaceStartTime { get; set; }
/// <summary>
/// Per-player race state, keyed by slot index.
/// </summary>
[Sync]
public NetDictionary<int, PlayerRaceState> PlayerStates { get; } = new();
private GhostPlayer _ghost;
private GhostRecording _pendingGhostRecording;
private float _pendingGhostLapTime;
/// <summary>
/// Ghost manager component for spawning/despawning ghosts.
/// </summary>
[RequireComponent]
public GhostManager GhostManager { get; set; }
/// <summary>
/// Live standings: continuous position and checkpoint-stepped gaps.
/// </summary>
[RequireComponent]
public RaceStandings Standings { get; set; }
/// <summary>
/// Get the race state for a player by slot index.
/// </summary>
public PlayerRaceState GetPlayerState( int slotIndex )
{
return PlayerStates.TryGetValue( slotIndex, out var state ) ? state : default;
}
protected override void OnEnabled()
{
base.OnEnabled();
// Preload ghost during WaitingForPlayers so it's ready when the race starts.
if ( State == GameModeState.WaitingForPlayers )
{
_ = PreloadLeaderboardGhost();
}
}
protected override void OnStateEnter( GameModeState state )
{
base.OnStateEnter( state );
switch ( state )
{
case GameModeState.Countdown:
AddText( "Get ready to race!" );
break;
case GameModeState.Playing:
RaceStartTime = Time.Now;
FinishCountdownActive = false;
// Reset lap clocks so the first lap doesn't include countdown time.
foreach ( var slot in PlayerStates.Keys.ToList() )
{
var ps = PlayerStates[slot];
ps.LapStartTime = Time.Now;
PlayerStates[slot] = ps;
}
Standings.ResetStandings();
StartGhostRecording();
SpawnPreloadedGhost();
AddText( "GO!" );
break;
case GameModeState.GameOver:
AddText( "Race Over!" );
break;
case GameModeState.Podium:
if ( WinnerSlot >= 0 )
{
AddText( $"Winner: {WinnerName}!" );
}
break;
}
}
protected override void OnStateExit( GameModeState state )
{
if ( state != GameModeState.Podium ) return;
WinnerSlot = -1;
WinnerName = "";
FinishCountdownActive = false;
DespawnGhosts();
PlayerStates.Clear();
Standings.ResetStandings();
}
protected override void SpawnAllPlayers()
{
base.SpawnAllPlayers();
// Initialize race state for each car
foreach ( var (slotIndex, go) in SpawnedPlayers )
{
if ( !go.IsValid() ) continue;
PlayerStates[slotIndex] = PlayerRaceState.Create();
}
}
protected override void OnPlayerJoined( Connection connection )
{
if ( State != GameModeState.WaitingForPlayers ) return; // mid-race joiners spectate
base.OnPlayerJoined( connection );
}
/// <summary>
/// Attach ghost recorders to non-bot players and start recording (singleplayer only).
/// </summary>
private void StartGhostRecording()
{
if ( !IsSinglePlayer ) return;
foreach ( var (_, go) in SpawnedPlayers )
{
if ( !go.IsValid() )
continue;
var car = go.GetComponent<Car>();
if ( car.IsValid() && car.IsBot )
continue;
var recorder = go.GetOrAddComponent<GhostRecorder>();
recorder.StartRecording();
}
}
protected override void OnStateTick( GameModeState state )
{
if ( state != GameModeState.Playing )
return;
// End the race when the post-finish countdown expires.
if ( FinishCountdownActive && FinishCountdownEndsAt <= 0f )
{
AddText( "Time's up!" );
TransitionTo( GameModeState.GameOver );
return;
}
// End when all real players finish; bots DNF.
var anyRealPlayers = false;
var allRealFinished = true;
foreach ( var (slotIndex, go) in SpawnedPlayers )
{
var car = go.IsValid() ? go.GetComponent<Car>() : null;
if ( !car.IsValid() || car.IsBot )
continue;
anyRealPlayers = true;
if ( !PlayerStates.TryGetValue( slotIndex, out var ps ) || !ps.HasFinished )
{
allRealFinished = false;
break;
}
}
if ( anyRealPlayers && allRealFinished )
{
TransitionTo( GameModeState.GameOver );
}
}
/// <summary>
/// Called via ICheckpointListener when a car enters a checkpoint trigger.
/// </summary>
public void OnCheckpointHit( Car car, Checkpoint checkpoint )
{
if ( car.Slot < 0 )
{
// Log.Info( $"Checkpoint hit but car has no slot" );
return;
}
var slotIndex = car.Slot;
if ( !PlayerStates.TryGetValue( slotIndex, out var state ) )
{
// Log.Info( $"{car.DisplayName} hit checkpoint {checkpoint.Index} but has no race state" );
return;
}
if ( state.HasFinished )
return;
// Must hit checkpoints in order
if ( checkpoint.Index != state.NextCheckpointIndex )
{
return;
}
Standings.RecordCheckpoint( slotIndex );
GameStats.Increment( "checkpoints", car: car );
if ( checkpoint.IsFinishLine )
{
CompleteLap( car, ref state );
}
else
{
state.NextCheckpointIndex = checkpoint.Index + 1;
// Reward progress; skipped on the finish line since that grants lap points instead.
car.Score?.RpcAdd( "Checkpoint", 10 );
}
PlayerStates[slotIndex] = state;
}
private void CompleteLap( Car car, ref PlayerRaceState state )
{
var lapTime = Time.Now - state.LapStartTime;
state.LastLapTime = lapTime;
var isNewPersonalBest = lapTime < state.BestLapTime;
if ( isNewPersonalBest )
state.BestLapTime = lapTime;
var lap = state.CurrentLap;
AddText( $"{car.DisplayName} completed lap {lap} ({lapTime:F2}s)" );
Scene.RunEvent<ILapTimeEvents>( x => x.OnLapCompleted( new LapCompleteInfo( car, lap, lapTime, isNewPersonalBest ) ) );
// Ghost recording (skip for bots and multiplayer)
GhostRecording lapRecording = null;
if ( IsSinglePlayer && !car.IsBot )
{
var recorder = car.GetComponent<GhostRecorder>();
if ( recorder.IsValid() )
{
recorder.StopRecording();
var carPath = car.Resource.IsValid() ? car.Resource.ResourcePath : "";
lapRecording = recorder.GetRecording( carPath, car.DisplayName );
recorder.StartRecording(); // restart for next lap
}
}
// Despawn ghost if beaten
if ( !car.IsBot && _ghost.IsValid() && lapTime < _ghost.LapTime )
{
Scene.RunEvent<ILapTimeEvents>( x => x.OnGhostBeaten( new GhostBeatenInfo( car, lapTime, _ghost.LapTime, _ghost.PlayerName ?? "" ) ) );
GhostManager.Despawn( _ghost );
_ghost = null;
}
// Stats record under the local player; only submit our own new best.
if ( car.IsLocalPlayer && isNewPersonalBest )
{
LapTimeService.SubmitLapTime( GetMapPath(), lapTime, lapRecording );
}
state.CurrentLap++;
state.NextCheckpointIndex = 0;
state.LapStartTime = Time.Now;
if ( state.CurrentLap > TotalLaps )
{
state.HasFinished = true;
state.FinishTime = Time.Now;
GameStats.Increment( "races-finished", car: car );
car.Autopilot = true; // let bot drive after finish
if ( WinnerSlot < 0 )
{
WinnerSlot = car.Slot;
WinnerName = car.DisplayName;
AddText( $"{car.DisplayName} finishes first!" );
GameStats.Increment( "wins", car: car );
}
StartFinishCountdown();
}
}
/// <summary>
/// Start the post-finish countdown. No-op if already running.
/// </summary>
private void StartFinishCountdown()
{
if ( FinishCountdownActive )
return;
FinishCountdownActive = true;
FinishCountdownEndsAt = FinishCountdownDuration;
AddText( $"Race ends in {FinishCountdownDuration:F0} seconds!" );
}
private void SpawnGhostFromRecording( GhostRecording recording, float lapTime )
{
_ghost = GhostManager.SpawnGhost( recording, lapTime );
}
/// <summary>
/// Map resource path for leaderboard lookups.
/// </summary>
private string GetMapPath()
{
return GameStats.CurrentMapPath();
}
/// <summary>
/// Fetch the leaderboard ghost during WaitingForPlayers so it's ready at race start.
/// </summary>
private async Task PreloadLeaderboardGhost()
{
_pendingGhostRecording = null;
_pendingGhostLapTime = 0f;
if ( !IsSinglePlayer )
return;
var mapPath = GetMapPath();
if ( string.IsNullOrEmpty( mapPath ) )
return;
var entry = await LapTimeService.GetNextAbove( mapPath );
if ( entry is null )
return;
var recording = await LapTimeService.FetchRecording( entry.Value );
if ( !recording.IsValid() )
return;
_pendingGhostRecording = recording;
_pendingGhostLapTime = (float)entry.Value.Value;
}
/// <summary>
/// Spawn the preloaded ghost. Called on entering the Playing state.
/// </summary>
private void SpawnPreloadedGhost()
{
if ( _pendingGhostRecording is null )
return;
if ( !IsSinglePlayer )
return;
SpawnGhostFromRecording( _pendingGhostRecording, _pendingGhostLapTime );
_pendingGhostRecording = null;
}
private void DespawnGhosts()
{
GhostManager.Despawn( _ghost );
_ghost = null;
}
protected override void DespawnAllPlayers()
{
DespawnGhosts();
PlayerStates.Clear();
Standings.ResetStandings();
base.DespawnAllPlayers();
}
}