Abstract base game mode component for a racing-style game. Manages game state machine (waiting, countdown, playing, game over, podium), player spawning (real and bot), slot management, timing, and transitions including returning to a lobby and broadcasting chat messages.
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 | ConVarFlags.GameSetting )]
public static bool FillWithBots { get; set; } = false;
/// <summary>
/// Max players (real + bots), derived from <see cref="MapResource"/>.
/// </summary>
public int MaxPlayers => Map?.MaxPlayers ?? 4;
/// <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 | ConVarFlags.GameSetting )]
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 | ConVarFlags.GameSetting )]
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()
{
DespawnAllPlayers();
if ( LobbyFlow.Current != null )
LobbyFlow.Current.ResetForNewRound();
if ( LobbyScene.IsValid() )
{
var options = new SceneLoadOptions()
{
};
options.SetScene( LobbyScene );
Game.ChangeScene( options );
}
else
{
Log.Warning( "BaseGameMode: No LobbyScene assigned, cannot return to lobby." );
}
}
/// <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;
}
}