Base game mode component that manages game state, player spawning, bot management, timers, lobby transitions and podium handling. It runs the state machine (Waiting, Countdown, Playing, GameOver, Podium), spawns player and bot prefabs into slots, shuffles starting grid, and handles returning to lobby and broadcast RPCs.
using Machines.Components;
using Machines.Events;
using Machines.Resources;
namespace Machines.GameModes;
/// <summary>
/// Abstract base for game modes: state machine, player spawning, lifecycle hooks.
/// </summary>
public abstract class BaseGameMode : Component
{
/// <summary>
/// The currently active game mode instance.
/// </summary>
public static BaseGameMode Current { get; private set; }
/// <summary>
/// Map resource for this scene, auto-resolved from the active scene if null.
/// </summary>
[Property]
public MapResource Map { get; set; }
/// <summary>
/// When true, empty slots are filled with bot players.
/// </summary>
[ConVar( "game_fill_bots", Saved = true, Flags = ConVarFlags.Replicated )]
public static bool FillWithBots { get; set; } = true;
/// <summary>
/// Max players (real + bots), derived from <see cref="MapResource"/>.
/// </summary>
public int MaxPlayers => Map?.MaxPlayers ?? 8;
/// <summary>
/// Prefab to spawn for each player.
/// </summary>
[Property]
public GameObject PlayerPrefab { get; set; }
/// <summary>
/// Scene to load when returning to the lobby.
/// </summary>
protected SceneFile LobbyScene => ResourceLibrary.Get<SceneFile>( "scenes/minimal.scene" );
/// <summary>
/// Podium stage in the scene (found even when disabled). Enabled during the Podium state.
/// </summary>
public GameObject PodiumObject => Scene.Components.GetInDescendants<PodiumStage>( includeDisabled: true )?.GameObject;
/// <summary>
/// Current state of the game mode.
/// </summary>
[Sync]
public GameModeState State { get; set; } = GameModeState.WaitingForPlayers;
/// <summary>
/// Timer tracking when the current timed state ends.
/// </summary>
[Sync]
public TimeUntil StateEndsAt { get; set; }
/// <summary>
/// Maximum duration of the waiting-for-players phase in seconds.
/// </summary>
[ConVar( "game_waiting_time", Saved = true, Min = 0, Max = 120, Flags = ConVarFlags.Replicated )]
public static float WaitingDuration { get; set; } = 30f;
/// <summary>
/// Reduced waiting duration once all expected players have joined.
/// </summary>
[Property]
public float WaitingReducedDuration { get; set; } = 5f;
/// <summary>
/// Duration of the countdown phase in seconds.
/// </summary>
[ConVar( "game_countdown_time", Saved = true, Min = 0, Max = 10, Flags = ConVarFlags.Replicated )]
public static float CountdownDuration { get; set; } = 3;
/// <summary>
/// Duration of the podium phase in seconds.
/// </summary>
[Property]
public float PodiumDuration { get; set; } = 15f;
/// <summary>
/// Pause on the game-over state before moving to the podium, so the finish can breathe.
/// </summary>
[Property]
public float GameOverDuration { get; set; } = 3f;
/// <summary>
/// Seconds remaining in the current timed state.
/// </summary>
public float TimeRemaining => MathF.Max( 0f, StateEndsAt );
/// <summary>
/// Spawned player GameObjects, keyed by slot index.
/// </summary>
protected Dictionary<int, GameObject> SpawnedPlayers { get; } = new();
/// <summary>
/// Connections seen this round, for late-join detection.
/// </summary>
private readonly HashSet<Guid> _knownConnections = new();
/// <summary>
/// Whether bots have already been spawned this round.
/// </summary>
private bool _botsSpawned;
protected override void OnEnabled()
{
Current = this;
ResolveMapResource();
// Podium should start disabled; correct it before OnStart runs.
MirrorPodiumToState();
if ( State == GameModeState.WaitingForPlayers )
StateEndsAt = WaitingDuration;
}
/// <summary>
/// Auto-resolve Map from the active scene if not set in the editor.
/// </summary>
private void ResolveMapResource()
{
if ( Map is not null )
return;
var activeScene = Game.ActiveScene?.Source;
if ( activeScene is null )
return;
Map = ResourceLibrary.GetAll<MapResource>()
.FirstOrDefault( m => m.Scene == activeScene );
}
protected override void OnDisabled()
{
if ( Current == this )
Current = null;
}
/// <summary>
/// True if we have authority (host or offline/dev mode).
/// </summary>
private bool IsAuthority => !Networking.IsActive || Networking.IsHost;
/// <summary>
/// True when there's only one real player - used to gate singleplayer-only features (e.g. ghosts).
/// </summary>
protected bool IsSinglePlayer => Connection.All.Count <= 1;
protected override void OnUpdate()
{
// Mirror podium state each frame (avoids missed one-shot RPC on late joiners).
MirrorPodiumToState();
if ( !IsAuthority )
return;
// Forget gone connections so a rejoin re-triggers OnPlayerJoined.
_knownConnections.RemoveWhere( id => !Connection.All.Any( c => c.Id == id ) );
// Detect newly joined players
foreach ( var connection in Connection.All )
{
if ( _knownConnections.Add( connection.Id ) )
OnPlayerJoined( connection );
}
switch ( State )
{
case GameModeState.WaitingForPlayers:
TickWaiting();
break;
case GameModeState.Countdown:
OnStateTick( State );
if ( StateEndsAt <= 0f )
TransitionTo( GameModeState.Playing );
break;
case GameModeState.Playing:
OnStateTick( State );
break;
case GameModeState.GameOver:
OnStateTick( State );
if ( StateEndsAt <= 0f )
TransitionTo( GameModeState.Podium );
break;
case GameModeState.Podium:
OnStateTick( State );
if ( StateEndsAt <= 0f )
ReturnToLobby();
break;
}
}
/// <summary>
/// Transition to a new state, calling lifecycle hooks.
/// </summary>
protected void TransitionTo( GameModeState newState )
{
var oldState = State;
State = newState;
OnStateExit( oldState );
OnStateEnter( newState );
Scene.RunEvent<IGameStateChanged>( x => x.OnGameStateChanged( oldState, newState ) );
}
/// <summary>
/// Tick for WaitingForPlayers: spawns cars and transitions to countdown when timer expires.
/// </summary>
protected virtual void TickWaiting()
{
SpawnAllPlayers();
// Fill remaining slots with bots once real players have spawned.
if ( !_botsSpawned && FillWithBots && SpawnedPlayers.Count > 0 )
{
SpawnBotPlayers();
_botsSpawned = true;
// Randomize the grid as soon as the field is full so the player isn't parked on pole.
ShuffleStartingGrid();
}
if ( AreAllPlayersReady() )
{
if ( StateEndsAt > WaitingReducedDuration )
StateEndsAt = WaitingReducedDuration;
}
if ( StateEndsAt <= 0f )
{
TransitionTo( GameModeState.Countdown );
}
}
/// <summary>
/// Returns true when all expected players have a car and MinPlayers is met.
/// </summary>
protected virtual bool AreAllPlayersReady()
{
if ( !Networking.IsActive )
return true;
var realCars = SpawnedPlayers.Values.Count( go => go.IsValid() && !(go.GetComponent<Player.Car>()?.IsBot ?? false) );
var minRequired = Map?.MinPlayers ?? 1;
// Connections beyond MaxPlayers are spectators; don't block countdown.
return realCars >= minRequired && realCars >= Math.Min( Connection.All.Count, MaxPlayers );
}
/// <summary>
/// Called on state entry. Override to add custom behavior.
/// </summary>
protected virtual void OnStateEnter( GameModeState state )
{
switch ( state )
{
case GameModeState.WaitingForPlayers:
StateEndsAt = WaitingDuration;
break;
case GameModeState.Countdown:
SpawnAllPlayers();
ShuffleStartingGrid();
StateEndsAt = CountdownDuration;
break;
case GameModeState.Playing:
break;
case GameModeState.GameOver:
StateEndsAt = GameOverDuration;
break;
case GameModeState.Podium:
StateEndsAt = PodiumDuration;
break;
}
}
/// <summary>
/// Called on state exit. Override to add custom behavior.
/// </summary>
protected virtual void OnStateExit( GameModeState state )
{
}
/// <summary>
/// Called each frame in a given state (host only). Override for gameplay logic.
/// </summary>
protected virtual void OnStateTick( GameModeState state )
{
}
/// <summary>
/// Spawn a player prefab at a SpawnPoint for each connection.
/// </summary>
protected virtual void SpawnAllPlayers()
{
foreach ( var connection in Connection.All )
{
SpawnPlayer( connection );
}
}
/// <summary>
/// Spawn a car for a connection. No-op if already spawned, no prefab, or race is full.
/// </summary>
protected virtual void SpawnPlayer( Connection connection )
{
if ( !PlayerPrefab.IsValid() )
{
Log.Warning( "BaseGameMode: No PlayerPrefab assigned." );
return;
}
PruneDeadEntries();
if ( SpawnedPlayers.Values.Any( go => go.IsValid() && go.Network.OwnerId == connection.Id ) )
return;
if ( SpawnedPlayers.Count >= MaxPlayers ) // full, this connection spectates
return;
var slot = NextFreeSlot();
var go = SpawnCarObject( slot, isBot: false );
if ( !go.IsValid() )
return;
go.Name = $"Player {connection.DisplayName}";
var car = go.GetComponent<Player.Car>();
if ( car.IsValid() )
{
// Use player's chosen car if set, else keep the prefab default.
var path = connection.GetUserData( "preferred_car" );
var preferred = string.IsNullOrEmpty( path ) ? null : ResourceLibrary.Get<Resources.CarResource>( path );
if ( preferred is not null )
car.Resource = preferred;
}
go.NetworkSpawn( connection );
SpawnedPlayers[slot] = go;
}
/// <summary>
/// Spawn a host-owned bot car in the next free slot.
/// </summary>
protected virtual void SpawnBot()
{
if ( !PlayerPrefab.IsValid() )
return;
var slot = NextFreeSlot();
var go = SpawnCarObject( slot, isBot: true );
if ( !go.IsValid() )
return;
var car = go.GetComponent<Player.Car>();
if ( car.IsValid() )
{
go.Name = $"Bot {car.DisplayName}";
// Bots get a random car.
var allCars = ResourceLibrary.GetAll<Resources.CarResource>().ToList();
if ( allCars.Count > 0 )
car.Resource = Game.Random.FromList( allCars );
}
var brainType = GetBotBrain();
if ( brainType is not null )
go.Components.Create( TypeLibrary.GetType( brainType ), true );
go.NetworkSpawn();
SpawnedPlayers[slot] = go;
}
/// <summary>
/// Clone the player prefab at the slot's spawn point and assign slot/bot identity.
/// </summary>
private GameObject SpawnCarObject( int slot, bool isBot )
{
var spawnPoint = Machines.Components.SpawnPoint.ForSlot( slot );
if ( !spawnPoint.IsValid() )
{
Log.Error( $"Couldn't find spawn point for slot {slot}" );
return null;
}
var go = PlayerPrefab.Clone( spawnPoint.WorldPosition, spawnPoint.WorldRotation );
var car = go.GetComponent<Player.Car>();
if ( car.IsValid() )
{
car.Slot = slot;
car.IsBot = isBot;
}
return go;
}
/// <summary>
/// Lowest slot index with no live spawned car.
/// </summary>
private int NextFreeSlot()
{
var i = 0;
while ( SpawnedPlayers.TryGetValue( i, out var go ) && go.IsValid() )
i++;
return i;
}
/// <summary>
/// Randomize the starting grid: every car (players and bots alike) is shuffled across the front
/// spawn points, packed densely from the pole. Repositions cars only (slots stay as identity);
/// standings derive from track progress, so the physical grid order is what counts. Run once the
/// roster is locked so players aren't always stuck on the pole next to the bots.
/// </summary>
protected void ShuffleStartingGrid()
{
if ( !IsAuthority )
return;
PruneDeadEntries();
var cars = new List<Player.Car>();
foreach ( var go in SpawnedPlayers.Values )
{
if ( !go.IsValid() )
continue;
var car = go.GetComponent<Player.Car>();
if ( car.IsValid() )
cars.Add( car );
}
if ( cars.Count <= 1 )
return;
var ordered = cars.OrderBy( _ => Game.Random.Float() ).ToList();
for ( var i = 0; i < ordered.Count; i++ )
{
var sp = Machines.Components.SpawnPoint.ForSlot( i );
if ( sp.IsValid() && ordered[i].Movement.IsValid() )
ordered[i].Movement.TeleportTo( sp.WorldPosition, sp.WorldRotation );
}
}
/// <summary>
/// Remove entries for destroyed cars so their slots free up.
/// </summary>
private void PruneDeadEntries()
{
foreach ( var slot in SpawnedPlayers.Where( kv => !kv.Value.IsValid() ).Select( kv => kv.Key ).ToList() )
SpawnedPlayers.Remove( slot );
}
/// <summary>
/// Called when a new connection appears. Default: evicts a bot if needed, then spawns the player.
/// </summary>
protected virtual void OnPlayerJoined( Connection connection )
{
MakeRoomForPlayer();
SpawnPlayer( connection );
}
/// <summary>
/// Returns the brain type to attach to bots. Override per gamemode for different AI.
/// </summary>
protected virtual Type GetBotBrain() => typeof( Player.RacingBrain );
/// <summary>
/// Fill empty player slots with bots up to MaxPlayers.
/// </summary>
protected virtual void SpawnBotPlayers()
{
PruneDeadEntries();
var botsNeeded = MaxPlayers - SpawnedPlayers.Count;
for ( int i = 0; i < botsNeeded; i++ )
{
SpawnBot();
}
}
/// <summary>
/// Remove a bot to make room for a late-joining player.
/// </summary>
public void MakeRoomForPlayer()
{
if ( !IsAuthority || State != GameModeState.WaitingForPlayers )
return;
PruneDeadEntries();
if ( SpawnedPlayers.Count < MaxPlayers )
return;
var bot = SpawnedPlayers.Values
.Select( go => go.GetComponent<Player.Car>() )
.Where( c => c.IsValid() && c.IsBot )
.OrderByDescending( c => c.Slot )
.FirstOrDefault();
if ( !bot.IsValid() )
return;
var slot = bot.Slot;
// Disable before destroy so the bot doesn't clip the joiner's spawn.
bot.GameObject.Enabled = false;
bot.GameObject.Destroy();
SpawnedPlayers.Remove( slot );
}
/// <summary>
/// Destroy all spawned player GameObjects.
/// </summary>
protected virtual void DespawnAllPlayers()
{
foreach ( var entry in SpawnedPlayers.Where( x => x.Value.IsValid() ) )
{
entry.Value.Destroy();
}
SpawnedPlayers.Clear();
}
/// <summary>
/// Get the spawned GameObject for a given slot index.
/// </summary>
public GameObject GetPlayerObject( int slot )
{
SpawnedPlayers.TryGetValue( slot, out var go );
return go;
}
/// <summary>
/// Return to the lobby scene.
/// </summary>
protected virtual void ReturnToLobby()
{
if ( !LobbyScene.IsValid() )
{
Log.Warning( "BaseGameMode: No LobbyScene assigned, cannot return to lobby." );
return;
}
// Play the transition on every peer; the rating prompt, despawn and scene load all happen
// inside the broadcast (rating per-peer, teardown + load behind the cover on the host).
BeginLobbyTransition();
}
/// <summary>
/// Plays the custom scene transition on all peers and, on the host, tears down the round and
/// performs the networked scene change once the overlay has covered the screen.
/// </summary>
[Rpc.Broadcast]
private void BeginLobbyTransition()
{
// Remember the played map so the lobby can ask this player to rate it (unless already rated).
// Runs on every peer so each player gets their own prompt, not just the host.
var ratingIdent = Map?.ResourcePath;
if ( !string.IsNullOrEmpty( ratingIdent ) && !Machines.Systems.GameStats.HasRatedMap( ratingIdent ) )
Machines.Systems.GameStats.PendingRatingMapIdent = ratingIdent;
// Match LobbyFlow's authority rule: host in a session, or the local player in single-player/editor.
bool isAuthority = !Sandbox.Networking.IsActive || Sandbox.Networking.IsHost;
System.Action onCovered = null;
if ( isAuthority )
{
var lobby = LobbyScene;
onCovered = () =>
{
DespawnAllPlayers();
if ( LobbyFlow.Current != null )
LobbyFlow.Current.ResetForNewRound();
if ( !lobby.IsValid() )
return;
var options = new SceneLoadOptions { ShowLoadingScreen = false };
options.SetScene( lobby );
Game.ChangeScene( options );
};
}
Machines.UI.SceneTransition.Show( "RETURNING TO LOBBY", null, onCovered );
}
/// <summary>
/// Broadcast a chat message to all connected players.
/// </summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
public void AddText( string message )
{
Sandbox.Platform.Chat.AddText( message );
}
/// <summary>
/// Sync podium GameObject enabled state to the current game state (runs locally, avoids missed RPCs).
/// </summary>
private void MirrorPodiumToState()
{
var podium = PodiumObject;
if ( !podium.IsValid() )
return;
var shouldShow = State == GameModeState.Podium;
if ( podium.Enabled != shouldShow )
podium.Enabled = shouldShow;
}
}