SSGX-R: Game Architecture

SlipStream GX code architecture is based on UE4 Gameplay Framework. Contains classes like GameInstance, GameMode, World, PlayerState, GameState, Actor, Pawn, etc. Their purpose is similar to respective classes in UE4.
I decided to use UE4 framework as a reference to make sure that the architecture will work well with online multiplayer.

I tried to make the architecture in a way that would allow multiple developers and artists to work on the game at the same time without causing too many conflicts.
The content is separated into multiple additive scenes, prefabs use nesting and settings are stored in assets instead of MonoBehaviours.

Many manager and utility type classes are exposed to other classes via interfaces which makes it possible to try different implementations without the need for refactoring.

In order to avoid big, monolithic scripts and prefabs, the functionality is separated by Game Mode. E.g. instead of having a single Pause Game prefab and a single script controlling it with many in-code conditions, there’s a separate script and prefab for each GM. With inheritance and nested prefabs all those scripts and assets share as much functionality as possible.

Each feature has its own folder where all scripts and assets used explicitely by this feature are stored. Shared assets are stored in folders common for the features that use them.

Here’s a Pause Menu feature folder as an example:

Pause Menu folder

Scenes

M_GameLoader is the first scene loaded in builds. The scene is empty. Game Instance (GI) prefab is instantiated into it.

Scene flow

After being instantiated, the GI State Machine activates CInitState which allows to run some debug code (necessary when running scenes directly). In this case it changes immediately to the GameLoaderSceneState which (at the time) doesn’t do anything except for changing the state to GameIntroState which in turn starts the game intro.

All scenes are stores in a Assets/Scenes folder and divided into subforlders by type (Game, TEST, UTILITY). Each scene has its own folder containing all assests and scripts uses explicitely by this scene (if not part of a feature). Assets shared between scenes are stored in a separate folder.

Scenes folder structure

Each scene filename has attached one of the following prefixes:

  • M – Main scene. Loaded in single mode. Becomes the Active Scene in Unity after loading.
  • P – Partial scene. Loaded in additive mode. Scene composes of a Main scene and at least one Partial scene is called a Composite scene.
  • T – Test scene. Used to test features and assets. Not included in development/release builds.
  • U – Utility scene. Usually contains tools and assets used to create assets used in game.

One of the benefits of using prefixes is that it’s easy to tell which folders belong to a scene as opposed to folders that only group assets. It’s also nice to know scenes’s purpose just by looking at its name.

Debug and testing folders are written in uppercase to denote that those contain assets not used in the game.

Prefabs are prefixed with PB_. It makes it easier to look for prefab in Project Window and also helps find bugs, e.g. when a GO on the scene should be a prefab (because it has the PB_ prefix) but it’s not linked to a prefab asset.

Classes

The most important MonoBehaviour classes grouped by context in which they appear.

Most important game classes grouped by context

The whole game currently can be divided in 4 major contexts:

  • Game-wide – classes that belong to this context are never destroyed and are accessible from any scene.
  • Main Menu – this is where all the race setup happens.
  • Gameplay – all gameplay scenes, i.e. where there’s a ship and a player controlling it.
  • Online – scenes related to online play; currently Lobby and Room.

Each class name is prefixed with a letter describing the type of the class. E.g. MGameMode where M is the prefix. It helps when searching for a class in Project Window and also prevents naming conflicts with namespaces and properties.

  • M – MonoBehaviours
  • B – Abstract classes. Takes precedence over the M prefix.
  • C – Class
  • F – Struct
  • E – Enum
  • O – UnityEngine.Object, e.g. ScriptableObject
  • S – Static class
  • I – Interface

Private class fields are prefix with m_, e.g. m_AudioSource. This helps distinguish them from local variables in methods.

Finite State Machine

Game Instance, Game Modes and UI controllers use Finite State Machine to setup the game/UI depending on the state they’re in.

FSM is only accessible by the class that created it. It listenes to game events and decides whether to change state or not. Current state of a State Machine is never used in the game code.

FSM state is only meant to setup the game state at the time the state is entered/exited. It can however execute code every update if necessary.

FSM doesn’t use strings or enums. It’s strongly typed and is using class types to reference states.

FSM classes:

  • CStateMachine – the FSM itself. Holds currently active state and all other states and transitions.
  • IStateMachine – FSM is usually only accessed through this interface. Allows to replace FSM implementation without breaking the code.
  • CStateMachineBuilder – used to construct FSM. Provides API to build FSM using method chaining.
  • CTransition – defines target state and a Signal that triggers the transition.
  • BState – base class for all FSM states.
  • IState – used by other FSM classes to reference a state.

Creating a new state requires extending from BState class. Each state allows to define behavior in OnEnter(), OnUpdate() and OnExit() methods. A state can have defined multiple transitions to other states.

A Transition is made of the target State (a State to transition to) and a Signal (described below) that triggers the Transition. Transitions are added to states with the Transition<TState, TSignal>() method.
States subscribe to Signals automatically on enter and unsubscribe on exit.

Signals

Signals are a global, type-safe messaging system. I used implementation by
Yanko Oliveira.

Each Signal is a type. User can subscribe to a Signal and he’ll be notified when a Signal is dispatched. Both subscription and dispatching can happen anywhere in the codebase.

Currently only FSMs subscribe to signals but this could change in the future.

The idea behind using FSM states and Signals together

There can be defined many different signals which can be dispatched from any gameplay / UI code. E.g. countdown end, ship destroyed, ship respawned, ship hit by a rocket, UI button pressed, etc.
The advantage is that it decouples classes from each other but it’s important to make sure that event handlers don’t rely on execution order.

Since Signals are global (i.e. can be listened from anywhere in the code) and the handler methods are always executed, it may be necessary to add some conditions to event handlers in case that the game/class is in a state where the handlers should not be executed (alternative would be to temporarily unsubscribe from the Signal).
But in that case, the handler would have to have access to some kind of information about the current state of the game/class. This could be solved by using boolean flags but that’s sth. that I don’t want to use.

Another way is to have a State Machine which holds the state, subscribe to a Signal by itself (as a part of a transition) and handle the Signal only when the state is active.

The rule that only FSMs can listen to Signals makes it easier to understand what may the effects of dispatching a Signal be.
Also, i tried to make sure that Signal are never chained, (i.e. that a Signal handlers can’t dispatch another Signal as a result) since this makes the control flow more difficult to follow.

Multiple FSMs can listen to the same Signal (as long as they don’t rely on Signal handler execution order).

FSM Usage

Below is an example of how FSM is created with a builder class. First state is activated via a separate method call. Whenever a signal is dispatched (e.g. GameIntroEndedSignal), the FSM gets notified and if the current state has a transition that triggers with that signal, it’ll switch to another state.

FSM used for Race End Sequence UI:

protected virtual void BuildFSM()
{
	StateMachine = new CStateMachineBuilder()
		.State<EmptyState>()
		.Transition<CRaceEndState, SessionEndedSignal>()
		.State<CRaceEndState>()
		.Transition<RaceResultsState, ContinueKeyPressedSignal>()
		.State<RaceResultsState>()
		.Transition<MenuState, ContinueKeyPressedSignal>()
		.State<MenuState>()
		.Transition<RaceResultsState, ShowRaceResultsSignal>()
		.Build();
}

Game Instance

MGI (Game Instance) represents the entire game/application. It’s a singleton, unconditionally instantiated into a scene when the game is loaded (also in empty scenes). The GI prefab is located in the Resources folder and is referenced from code by name.

This method instantiates the GI prefab before first scene loads:

[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void OnLoad()
{
	InstantiateGIPrefab();
}

PB_GameInstance prefab contains not only the MGI script but also all the other global managers. Global means that these script instances will exist and be available throughout the entire lifetime of the application.
MGI is marked as DontDestroyOnLoad and therefore all the child GameObjects (GOs) will not be destroyed.

Content of the GameInstance prefab

GI references all managers and exposes their interfaces to other game classes. Global managers have access to GI only through IManagerGameInstance.

Global Manager dependency diagram

MGI implements two interfaces; IGameInstance (available globally) and derived IManagerGameInstance (for global managers).
Having different GI interfaces allows global managers to have a full access to the MGI while IGameInstance provides limited GI access for the rest of the codebase.

public interface IGameInstance
{
	ISceneManager SceneManager { get; }
	IInputManager InputManager { get; }
	IAudioManager AudioManager { get; }
	INetworkManager NetworkManager { get; }
	IScreenFader ScreenFader { get; }
	ORaceSettings RaceSettings { get; }
	MCmdConsole CmdConsole { get; }
	void ShowIMGUI(bool state = true);
}

Global managers are not available directly to the codebase as singletons. Instead, they provide functionality through interfaces which are made available to the codebase via the MGI singleton class. This allows for creating different versions of any manager and replacing the one being exposed by the MGI (also at runtime).

MGI is turned into singleton by inheriting from public abstract class BGISingleton : MonoBehaviourPunCallbacks where T : MonoBehaviour where T1 : class. It’s the only singleton in the game.

All global manager inherits from BGIComponent<T> where T is GI interface used by the manager to access GI.

GI FSM

GI class creates a State Machine that is driving the game. GI doesn’t operate the FSM. Instead, the FSM has access and calls methods on the GI. FSM listenes to game events and changes active state accordingly. When the state changes, the FSM calls methods on the GI to setup the overall game state, e.g. load a new scene.

On the top-most level, the game is divided into several states like Game Intro, Main Menu, Gameplay, etc. Here’s the GI FSM construction method:

private void CreateFSM()
{
	m_StateMachine = new CStateMachineBuilder()
		.State<CInitState>()
		.State<CGameLoaderSceneState>()
		.Transition<CGameIntroState, GameLoaderDoneSignal>()
		.State<CGameIntroState>()
		.Transition<CMainMenuState, GameIntroEndedSignal>()
		.State<CMainMenuState>()
		.Transition<CGameplayState, StartRaceSignal>()
		.Transition<CLobbyState, EnterLobbySignal>()
		.State<CLobbyState>()
		.Transition<CRoomState, EnteredRoomOnServerSignal>()
		.Transition<CMainMenuState, ReturnToMainMenuSignal>()
		.State<CRoomState>()
		.Transition<CLobbyState, EnterLobbySignal>()
		.Transition<CMainMenuState, ReturnToMainMenuSignal>()
		.Transition<CGameplayState, StartRaceSignal>()
		.State<CGameplayState>()
		.Transition<CMainMenuState, ReturnToMainMenuSignal>()
		.Transition<CGameplayState, RaceAgainSignal>()
		.Transition<CRoomState, ReturnToRoomSceneSignal>()
		.Transition<CLobbyState, LeaveRoomSignal>()
		.Build();
}

GI FSM states have access to SceneManager.sceneLoaded and ISceneManager.CompositeSceneLoaded events through a base class. They’re subscribed to these events only while being active.

Init State

This is the default state. It checks the current scene name or type (e.g. gameplay scene) and loads appropriate next state. E.g. if current scene is a gameplay scene, it’ll change state to the gameplay state. The gameplay state will then load a race scene according to the settings in Race Settings asset.

Having Init state allows for running scenes directly. Each scene requires the environment being in a proper state (mainly the state of GI FSM) and the Init state takes care that.

Gameplay State

After all race settings in the Main Menu are set (selecting game mode, track, ship) and the player pressed button to start the game, a signal is sent (StartRaceSignal). GI FSM reacts to it by changing from MainMenuState to the GameplayState.

OnEnter() the Gameplay state enables offline mode (which allows using multiplayer code to play an offline game) and loads a gameplay scene according to the current Race Settings.

protected override void OnEnter()
{
	GameInstance.NetworkManager.EnableOfflineMode();	
	GameInstance.SceneManager.LoadSceneFromRaceSettings();
}

Gameplay state also listenes to UnitySceneManager_SceneLoaded and SceneManager_CompositeSceneLoaded callbacks. After the Main scene is loaded (scene set in Unity as the active scene) it spawns the Game Mode (GM) and initializes the World class.

protected override void UnitySceneManager_SceneLoaded(Scene scene, LoadSceneMode mode)
{
	if (mode != LoadSceneMode.Single)
	{
		return;
	}

	m_GameMode = GameInstance.InstantiateGameMode();
	m_World = SGameplayStatics.FindGameObjectByTag<MWorld>(ETags.World);
	m_World.GameMode = m_GameMode;
}

Then it waits until all partial scenes finish loading to initialize the GM and World. All other scenes need to be already loaded because each of them can contain objects that the GM and World needs to reference. Those objects are searched by Tag.

protected override void SceneManager_CompositeSceneLoaded(EMainScene scene)
{
	m_World.Init();
	m_GameMode.Init();
}

Ship Destroyed State

When the ship is destroyed during the race, it’s no longer a Race state since the player is not in control of his ship. The state is changed to BShipDestroyedState which handles deactivation of PC, changing cameras, ship destroy animation and respawning.

Code executed when entering the BShipDestroyedState:

protected override void OnEnter()
{
	m_SPPerc = GameMode.TrackSpline.GetClosestSPPerc(GameMode.Ship.transform.position);

	GameMode.PlayerController.gameObject.SetActive(false);
	GameMode.ShipCamera.EnableCamera(false);
	GameMode.DestroyedShipCamera.EnableVCam();
	GameMode.Ship.gameObject.SetActive(false);
	GameMode.PositionDestroyedShip();
	GameMode.DestroyedShip.gameObject.SetActive(true);

	Observable.Timer(new TimeSpan(0, 0, 0, 3)).Subscribe(_ => { SSignals.Get<RestoreShipSignal>().Dispatch(); });
}

RestoreShipSignal is dispatched a few seconds later which makes the FSM change state back to the Race state. OnExit() reverts changes made by the Ship Destroyed state.

Using signals to change states avoids the need to hard-bind one state to another. Which state is triggered by a signal is defined in transitions when the FSM is created.

Other States

Other GI states usually only load related scene. Exception is the CLobbyState which waits for the scene to load before connecting to PUN (Photon Unity Networking).

CLobbyState.OnEnter():

protected override void OnEnter()
{
	GameInstance.RaceSettings.GameMode = EGameMode.Online;
	MGI.Instance.SceneManager.LoadScene(EMainScene.M_Lobby, LoadSceneMode.Single, false, false, false, false);
}

CLobbyState.UnitySceneManager_SceneLoaded():

protected override void UnitySceneManager_SceneLoaded(Scene scene, LoadSceneMode mode)
{
	// Connect to PUN only after the Lobby scene finished loading.
	if (MSceneManager.SceneStringToEScene(scene.name) == EMainScene.M_Lobby)
	{
		MGI.Instance.NetworkManager.ConnectToPUN();
	}
}

Multiscenes

Each main scene in the project can consist of multiple additively loaded partial scenes. Currently this is used only in the gameplay scenes.
Having assets split across multiple partial scenes makes working in a team easier.

Content of the CassandraDay scene

In editor, all partial scenes are loaded automatically when user loads the main scene. It’s done with the CEditorLevelManager class which initializes itself on load via the [InitializeOnLoad] attribute, listenes to scene loaded events and loads partial scenes if available.

Partial scenes are defined for each main scene in a OPartialScenes ScriptableObject asset.

Partial scenes that make up the AlphardDay scene

In runtime, composite scene loading is handled in MSceneManager class (part of GI prefab).

Race Settings

In the Main Menu, when the player selects race settings, those are saved in a RaceSettings asset. The same asset contains settings for offline and online races. It’s accessible to all classes via Game Instance.
Aside for race settings it contains debug options and helper methods for reading/updating the settings.

Start Race debug settings allow to run the scene without starting a race

Debug Settings

  • Start Race – if true, when opening a gameplay scene directly, the race will start. Useful to quickly playtest the gameplay.
  • Use AIPC – control over the ship will be taken over by a simple AI. I was using it mostly for physics and camera testing and balancing.
  • Enable Teleporters – enables teleporters that teleport the ship between checkpoints. It allows to finish a full lap in a matter of seconds. Can be used to test anything that requires full laps to be finished.

Debug settings won’t have any effect in release build.

Direct Scene Loading

Each main scene in the game can be run directly (without the need to start the game from the GameLoader scene) while preserving as much of its functionality as possible. It saves a lot of time while testing.

When a scene is loaded, the GI prefab is always spawned first. The GI FSM starts in the CInitState which checks what scene is currently loaded and changes the FSM state accordingly. E.g. when running a gameplay scene, it’ll change to the CGameplayStatestate which will load the gameplay scene using settings from the RaceSettings asset file.

Game Modes

Game Mode (GM) represents the type of game that can be played on a gameplay scene, e.g. Time Trial, Speed Lap, Race against AI, Online Race, etc.

Each GM is represented by a separate class e.g. (MSpeedLapGameMode). Since Game Modes share a lot common functionality, there’s a hierarchy of base classes.

Game Mode type hierarchy

Here’s an example of a GM Init method. It’s called by GI FSM CGameplayState right after the composite scene is loaded.

I try to avoid long method call chains which could make the code harder to understand.

public override void Init()
{
	RaceSettings.LapsCount = 99;

	InitCounterboard();
	InstantiatePauseMenu();
	StartInputCoroutine();
	SpawnPlayerShip(ELocalPlayerID.FirstPlayer);
	InstantiateShipCameraSystem();
	InstantiateDestroyedShip();
	InstantiateShipDestroyedCamera();
	InstantiatePlayerController(ELocalPlayerID.FirstPlayer);
	InstantiateLocalPlayerState(ELocalPlayerID.FirstPlayer);
	PlayerController.PossessPawn(Pawn);
	InstantiateAIPC();
	InstantiateHUD(ELocalPlayerID.FirstPlayer);
	InstantiateRRS();
	SetupSpeedClass();
	InstantiateMusicPlayer();

	TrackSpline = SGameplayStatics.GetSplineAdapter(ETrackSplineType.Dreamteck);
	SSignals.Get<GameModeInitialized>().Dispatch();
}

Game Mode State Machine

Each Game Mode has its own State Machine. Some states can be used by multiple GMs and some GMs need their own specific states. Because each GM has its own FSM, GMs can differ significantly in functionality.

States handle scene setup, e.g. enable/disable track intro, start countdown, enable possibility to pause the game, show HUD, etc.

E.g.CountdownState prepares the scene for race countdown :

public abstract class BCountdownState<T> : BGMStateBase<T> where T : BRaceGameMode
{
	protected override void OnEnter()
	{
		World.SinglePlayerHUD.gameObject.SetActive(true);
		GameMode.ShipCamera.EnableCamera();
		World.PrimaryCounterboard.StartCountdown();

		SDebugEventManager.TriggerEvent("DE_CountdownStateOnEnter", this);
	}
}

Generally, each GM as at least these states:

  • Intro – camera flythrough the track with a voice commentary.
  • Countdown
  • Race
  • Race Result Screens – part of Race End Sequence. These are UI screens shown to the player one after another after session/race end.

Speed Lap GM FSM construction method:

protected override void BuildFSM()
{
	m_StateMachine = new CStateMachineBuilder()
		.State<EmptyState>()
		.Transition<SLTrackIntroState, GameModeInitialized>()
		.State<SLTrackIntroState>()
		.Transition<SLCountdownState, StartCountdownSignal>()
		.State<SLCountdownState>()
		.Transition<SLRaceState, CountdownEndedSignal>()
		.State<SLRaceState>()
		.Transition<SLRESState, SessionEndedSignal>()
		.State<SLRESState>()
		.Build();
}

GM Prefab Instantiation

GM prefab is instantiated by Game Instance from the CGameplayState as soon as the main scene is loaded. Which GM prefab is instantiated depends on currently selected GM in the RaceSettings.

Each GM script is attached to a separate prefab.

Folder containing all GM scripts and prefabs

After instantiation the GM does internal setup, creates FSM and activates an empty state. After the composite scene is loaded, GI calls Init() on the GM. At the end of the initialization, a signal is sent that makes the FSM move the the Track Intro state which start the race track intro.

World

MWorld class instance is part of every gameplay scene. It represents the game world (race track, environment, etc.) and manages all entities (mainly Actors) in it. Each scene has it’s own World instance with unique setup.

World is responsible for instantiating (or spawning – in case of networked objects) GameObjects. When an Actor is instantiated (or when pre-existing Actor starts), it registers in the World which adds a reference to it to a cache.

World method responsible for spawning networked GameObjects:

public T SpawnActor<T>(ESpawnPrefabName prefabName, Vector3 position, Quaternion rotation) where T : MActor
{
	GameObject spawnedGO = PhotonNetwork.Instantiate(prefabName.ToString(),	position, rotation, 0);

	T comp = spawnedGO.GetComponent<T>();
	if (comp)
	{
		FileLogger.LogString(this, LogCategories.FLNetwork, string.Format("Prefab {0} spawned!", prefabName.ToString()));
	}
	else
	{
		Debug.LogWarning("Actor prefab doesn't have MActor component attached!");
	}
	return comp;
}

Actors

World entities are called Actors. Each Actor inherits from the MActor class. MActor is a MonoBehaviour with access to the current Game Mode (via IActorGameMode) and World (IActorWorld). Actors automatically register itself within the World right after instantiation.

Actor responsibilities:

  • Acquire reference to the World class.
  • Register in World.
  • Provides Race Settings asset reference to derived classes.
  • Listens to World messages.

Actors derive from BBaseMonoBehaviour and therefore have access to following callbacks:

  • void UnitySceneManager_SceneLoaded(Scene scene, LoadSceneMode loadMode)
  • void SceneManager_CompositeSceneLoaded(EMainScene scene)
  • void OnSecondFrame()

Actor classes are the only ones that can listen to World messages (World sends messages only to the Actors).

Messages that the World can send to Actors

World message system makes it possible for Actors to react to game events without having a need to reference other Actors or check game state.

World messages are sent only after the game internal state is already updated (mainly APlayerState and AGameState).

World Messages

World can send messages to all Actors (similarly how Unity is sending messages to all MonoBehaviours). However, World does not decide when to send a message. Only the GM can make World send a message – it does it through a Notify...(), e.g. NotifyRaceStarted().

Method used by the World to send message to all Actors :

private void SendMonoMessage(string message, params object[] parameters)
{
	foreach (MActor actor in m_Actors)
	{
		actor.EM_InvokeMethod(message, parameters);
	}
}

Player State

Player State (PS) is an Actor class created for every local and remote player, on all clients, in single and multiplayer game modes. Keeps info about player-race relevant data like current lap, number of speed pads triggered, lap times, etc. This class is replicated to all other clients.
Each Game Mode defines its own PS. All inherit from a common BPlayerState base.

Player State is not controlled by any other class. It collects race data by itself. As an Actor it has access to the World and Game Mode. It listenes to World messages.

Since other gameplay classes often retrieve data from PS (via IPlayerState – exposed in GM) it’s important that the PS gets updated first (e.g. on new lap) before any other class has a chance to ask it for data.
For this reason, for some events, PS is notified directly by the GM (e.g. viaNotifyShipCrossedStartline()). After PS state is updated, GM is sending the World message.

Public PS properties available to other classes via IPlayerState:

  • InvincibleMode – if true, the ship can’t be damaged.
  • SpeedPads – number of speed pads triggered during current session.
  • Lap – current lap number. Before session start, current lap is set to 0.
  • LapTimes:List<FLapTime> – list of finished laps in current session. FLapTime includes lap number, time and number of speed pads.
  • LapTime – time of the lap currently in progress.
  • BestLapTime – time of the best lap so far (during this session). Updated at the end of each lap.
  • RecordTime – best lap/session time from all previous sessions.
  • BestEverLapTime – best lap/session time ever made by the player. Takes into account lap times from this and all previous sessions.
  • SessionTime – time of the current session. Starts counting on countdown end.
  • IsPenultimateLap – returns true if there’s one more lap to make after the current one.
  • IsFinalLap – returns true if current lap is the last one.
  • IsPerfectLap – it’s set to false on any wall collision and reset to true after each lap.

At the end of a session (SL/TT), PS saves race records to a file (currently it’s JSON – for testing). PS reads records data in Awake() using SPersistentStorage static utility class.
Currently records file is stored in StreamingAssets folder to make it easy to preview and modify in builds.

Session records data file structure:

[Serializable]
public class CTimeRecords
{
	public List<CTimeRecord> Records = new List<CTimeRecord>();
}

[Serializable]
public class CTimeRecord
{
	public EGameMode GameMode;
	public ETrack Track;
	public ERaceDirection Direction;
	public ESpeedClass SpeedClass;
	public float Time;
}

Game State

Actor class. AGameState (GS) keeps track of game data that should be accessible by all players in the room (e.g. results of a race). It’s updated on the master and replicated to all clients. Similar to Player State, it listenes to World (also PUN) messages and GM notifications to update its state. Provides data to gameplay classes via IGameState (exposed in GM). It’s not used in offline game modes (could be used for split-screen though).

Public API:

  • int LocalPlayerPosition – returns local player race position.
  • int GetPlayerRacePosition(int actorNumber) – returns race position for given player (PUN ActorNumber).
  • List<FRaceResult> GetRaceResults()FRaceResult struct consists of player name, race time and placing. Currently race results are only updated when a ship (any ship) triggers a checkpoint (it’s only a temporary solution).
  • void NotifyPlayerTriggeredCheckpoint(int actorNumber) – used by GM to notify GS that a ship triggered a checkpoint.

Static Utility Classes

  • SUtilities – Various utility methods (string, find, enum, math, etc.)
  • SGameplayStatics – Helper methods used only in a gameplay scene.
  • SExtensionMethods – All custom extension methods.
  • SPersistentStorage – Includes helper methods for saving data to persistent storage like PlayerPrefs or JSON.
  • SDampingFunctions – All damping functions used across the game.
  • SGlobalConstants – Constants used across the game (e.g. time format for race times).
  • SEditorUtilities – Editor utilities available from Unity dropdown menus.
  • SDebug – Methods used for debugging.

UI System

Each UI screen is a separate prefab. Multiple UI screens are nested together in a single prefab with a script at the root GO that controls them, e.g. PB_MainMenuUI prefab is controlled by MMainMenuUI script.

PB_MainMenuUI contains multiple screens
A single UI screen

There’re separate UI prefabs for Main Menu, Pause Menu and Race Results Screens.

Root script is mainly responsible for displaying child screens on the screen. It also handles input and provides functionality to the child screens.

Each root script has its own State Machine. Each state is responsible for showing a specific screen (which may consist of multiple panels). FSM reacts to game events like pressing the Submit button and changes state if necessary.

Screens

Each screen has attached a View and ViewController (VC) script. View references all UI elements and exposes API to the VC. It does not have any functionality (except for methods that help manipulate the UI) and does not reference any other game classes.

VC controls the View. It doesn’t have access to any other game classes except for GI, View and the Root UI component (e.g. MMainMenuUI). It gets references to the View and Root components by inheriting from BViewControllerT<TView, TRoot> class.
All data needed to update the View is provided by the Root component.

UI System Dependency Diagram

Each abstract BViewController has a reference to the default Selectable (e.g. a button) (can be assigned in the inspector) that gets selected when a panel is opened.
It keeps also a reference to the current selected UI element. When player goes back to the same screen, the last selected button will be highlighted.

In order to simplify editing UI elements, all screen within a single prefab are positioned side by side instead one over another in the viewport. This way a designer can edit each screen without the need to disable other screens. In VC Awake(), screen RectTransform location and size is restored to be in the center of the viewport.

Since all screens in a prefab are enabled by default there’s no possibility of accidentaly saving screens in the wrong state. Screens that should not be visible by default in runtime can be disabled in prefab override (in a variant or nested prefab) or in the scene.

Main Menu Camera Animations

Each Main Menu screen (like Quick Play or Track Select) has it’s own set of camera animations called shots. Each shot is composed of CinemachineVirtualCamera and a dolly path.

PB_Animations prefab

Animations playback is controlled by a timeline.

Timeline defines which shot will be played when for how long

There’s a single PlayableDirector responsible for playing all playable assets. 
Camera shots for each screen are defined in separate playable assets.

PB_Animations prefab inspector

Here you can see playable assets being swapped at runtime depending on currently active UI screen.

Race HUD

Race HUD consists of lap counter, target race time, target race, target lap time, current lap time, 3 last lap times, integrity and speed indicator.

Race HUD UI

HUD is instantiated by a Game Mode. It’s not controlled by other gameplay classes. After being enabled, it listenes to World messages and updates itself.

There’re separate HUD prefab variants made for different Game Modes. The View component in each prefab is the same but the ViewControllers are different. Each handles GM specific behavior. If necessary, different View types can be also used which gives significant flexibility in deciding about HUD look and behavior.

BRaceHUDVC protected members:

  • View – reference to the View MonoBehaviour that references HUD Unity UI components.
  • Root – references the Root component.
  • InitUIElements() – disables UI elements that should not be visible in current GM and initializes numeric values.
  • PullHUDData() – updates Data property of type FHUDUpdateData with data pulled from IShip and IPlayerState. This is done every frame.
  • UpdateHUD() – updates HUD UI using info from the Data property.
  • UpdateLapTimeDiff() – called from World_LapFinished(). Updates lap time difference on the View.
  • ShowLapTimeDiff() – shows lap time diff on screen (via View) and and hides it after predefined time.
  • World_LapFinished() – pulls HUD data and updates the View. Player State and Game State classes are not yet notified that a new lap started so they hold data related to the lap that just finished.
  • World_LapStarted() – same as above. The difference is that now the Player State and Game State classes are updated so they have info about the lap that just have started.
  • World_PlayerFinishedRace() – hides HUD.

Pause Menu

Pause Menu can be enabled with ESC key on the keyboard or Options button on DualShock 4 controller. In online GM, Pause Menu doesn’t stop the gameplay.

Pause Menu in game

PauseMenu folder holds all the scripts and assets used exclusively by the Pause Menu. Scripts shared with other UIs are located at /Assets/Scripts/UI/.

Pause Menu folder structure

Pause Menu panels use the same base View and ViewController classes as other UIs in the game (e.g. Main Menu).

At the root of the Pause Menu GO is an Actor script. Each GM has its own variation which inherits from abstract BPauseMenu.

Pause Menu behavior is driven by a state machine. Each UI screen has a corresponding state. There’s a CGameUnpausedState which binds to the ESC key and changes the game-wide pause state (it holds the state).

States are changed in response to Signals which may be sent from any place in the gameplay code in reaction to e.g. user input.
E.g. PauseGameSignal is dispatched from the BPauseMenu on ESC key press and from the VC when the “Continue” button is pressed.

In contrast to the typical control flow where the GM is deciding about game state and passing this info to Actors via World messages, here the Pause Menu FSM decides whether the game is paused or not. It then notifies GM about current game pause state and the GM handles pause on its own.
The reason for that is that the same key (ESC) is responsible not only for toggling the pause state but also navigating the pause menu. It was creating problems when both the GM and the Pause Menu were handling the same key input.
Current implementation doesn’t seem to have any negative effects. If the Pause Menu GO is never spawned, there’s imply is no game pause functionality in the game.

Race End Sequence

Race End Sequence (RES) includes everything that happenes after player finishes the session/race until he leaves the gameplay scene.

Most notably it includes the Race Result Screens (RRS).

Race Results Screens

Race Results Screens (RRS) is a set a UI screens that are shown to the player one after another to present him the results of the race.

endsession debug command enables the Race Results Screens

RRS can be enabled in response to multiple events – race/session end, Pause Menu “End Session” button press, console command. Each of these events dispatches the SessionEndedSignal which is binded to the CRaceEndState FSM state on the RRS Actor.
GM FSM is also listening to the same signal and changes its state to Race End Sequence state. In this case, the order in which the Signal handlers are called doesn’t matter.

Online Multiplayer

Online multiplayer is based on Photon Unity Networking (PUN). It’s using lobbys and rooms. All communication between clients goes through Photon server.

PUN server is not authoritative, it doesn’t control the rules of the game – it is only replicating data. Player that creates a room becomes a master client which is like any other client with additional responsibility of managing gameplay events, e.g. synchronizing race countdown or returning players to the room after the race. Master client has also the authority to decide race outcome since it’s the one that updates Game State (AGameState class).

By default the game is not connected to the PUN or using any of the offline PUN features. The game connects to PUN server only after the player decides to play an online race. For offline races, the offline PUN mode is enabled just before loading the gameplay scene since offline since it is required only during the race.

Currently, player can browse available rooms in a Lobby or create a new one. Max 8 player can race at the same time. After the race each player is informed about his final position and all players go back to the room. Race settings for online game are fixed atm and cannot be changed by the player.

MNetworkManager (part of GI prefab) keeps network related functionality, i.e. listenes to PUN messages and provides API for networked game classes.

This video shows how to start a multiplayer race.

Lobby, Room and Race Start Sequence

Lobby

In Main Menu, the “Online” button triggers EnterLobbySignal, the GI FSM enters CLobbyState which loads M_Lobby scene. It waits for the Lobby scene to load and then calls MNetworkManager.ConnectToPUN().

In the M_Lobby scene, MLobbyViewController script controls Lobby UI and all the functionality. First it deactivates all network buttons, waits for the OnConnectedToMaster() callbacks and calls PhotonNetwork.JoinLobby().

After receiving OnJoinedLobby() callback, it activates network buttons and asks PUN to get a list of all available rooms. It passes this information to MRoomListViewController which is responsible for UI panel that lists all rooms as buttons.

Once player pressed a room button, MRoomListViewController asks PUN to join the room. On success, OnJoinedRoom() callback dispatches EnteredRoomOnServerSignalwhich makes GI FSM change to CRoomState and load the M_Room scene.

Room

Room scene is controlled by MRoomViewController script. It only has one button – “Start Game” – which is only active for the master client. Other clients must wait for the player on the master client to decide to start the race.

There’s a separate UI panel listing all player in a room. In Start() the list is initialized using data from PhotonNetwork.PlayerListOthers and then updated with PUN OnPlayerEnteredRoom() and OnPlayerLeftRoom() callbacks.

When the “Start Game” button is pressed, the room gets closed on the server side and MNetworkManager.LoadGameplayScene() is executed. It sends EnterGIGameplayState net event which makes the clients dispatch StartRaceSignal and change GI FSM state to CGameplayState as a result (which load the gameplay scene and activates the Online Game Mode).

Online Game Mode

MOnlineGameMode (OGM) class manages online races. Inherits from BRaceGameMode, same as SL and TT. It’s spawned the same way as any other game mode, when the gameplay scene is loaded.

OGM Init()differes from SL/TT e.g. in that it doesn’t instantiate player ship but does spawn Game State. Player ship will be spawned after all players load their gameplay scenes. Game State is only available in online GM – spawned by the master client and replicated to all other clients.

At the end of GM Init(), each client sends TrackLoaded event to master client which is reponsible for synchronizing and managing the race among all participants.
Since the Init() is called by the Game Instance after the composite scene finished loading and the TrackLoaded event is sent after the GM finished all initialization, the scene now is ready to start the race.

public override void Init()
{
	InitCounterboard();
	InstantiatePauseMenu();
	StartInputCoroutine();
	InstantiateDestroyedShip();
	InstantiateShipDestroyedCamera();
	InstantiatePlayerController(ELocalPlayerID.FirstPlayer);
	InstantiateLocalPlayerState(ELocalPlayerID.FirstPlayer);
	InstantiateAIPC();
	InstantiateHUD(ELocalPlayerID.FirstPlayer);
	InstantiateRRS();
	InstantiateMusicPlayer();
	SpawnGameState();

	TrackSpline = SGameplayStatics.GetSplineAdapter(ETrackSplineType.Dreamteck);

	// The scene is now fully loaded and initialized. Inform the master about it.
	Client_SendTrackLoadedNetEvent();

        // Inform FSMs that the GM finished initialization.
	SSignals.Get<GameModeInitialized>().Dispatch();
}

Race Start Sequence

This sequence diagram shows all the network communication that happenes between all room clients and the master client before the race starts. Master client is also a client so it’ll receive its own events (there might be exceptions).
In this diagram all arrows comming from the Clients to the Master side mean that the Master received this specific client event from all of the clients.

Network communication serquence diagram

The StartCountdownSignal makes the OGM FSM switch to OnlineCountdownState which activates the countdown sequence on each of the clients, at the same time.
The countdown start time was passed to all clients as a payload with the StartCountdown network event. This time was 1 second in the future (using PUN server time) so that all clients have time to receive the messages and start the countdown at exact same moment.

Master_SendStartCountdownNetMessage() sends a StartCountdown net event from the master to all clients (including self), making them wait until specific time and start the countdown:

private void Master_SendStartCountdownNetMessage()
{
	RaiseEventOptions options = new RaiseEventOptions()
	{
		Receivers = ReceiverGroup.All
	};

	byte[] payload = BitConverter.GetBytes(PhotonNetwork.ServerTimestamp + m_StartCountdownDelay);
	MNetworkManager.SendNetEvent(ECustomPunEvent.StartCountdown, payload, SendOptions.SendReliable, options);
}

This is network event handler for the StartCountdown event (executed on all clients):

private void Client_HandleStartCountdownNetEvent(object payload)
{
	// get payload
	byte[] timestampBytes = payload as byte[];
	if (timestampBytes == null)
	{
		FileLogger.LogString(this, LogCategories.FLNetwork, "PUN event payload is null!");
		return;
	}

	// get timestamp from payload
	int timestamp = BitConverter.ToInt32(timestampBytes, 0);

	// start synced countdown
	StartCoroutine(StartCountdownSynced(timestamp));
}

Race End Sequence

When a client finishes the race, its GM FSM changes to OnlineRESState (RES – Race End Sequence) which calls Client_SendPlayerFinishedRaceNetEvent(). This event is sent to all clients so that they can trigger their World.NotifyPlayerFinishedRace() and World.NotifyAllPlayersFinishedRace() messages. Master client is storing the number of players that finished the race.

Player that finish the race before others, will see the Race Result Screens and wait there until the race end.

When all players finish, the master client sends ReturnToSceneRoom net event (Master_SendReturnToRoomSceneNetMessage()). Clients will call ReturnToRoomSceneSignal in response which will make them change FSM state to CRoomState and load the Room scene.

Other

Other than in SL and TT modes, during online game, pause button opens the Pause Menu but doesn’t pause the game. This is implemented by overriding thePauseGame(bool state) in OGM:

public override void PauseGame(bool state = true)
{
        // this code is omitted in OGM
	// Time.timeScale = state ? 0.0f : 1.0f;

	MGI.Instance.AudioManager.PauseAllSounds(state);
	World.NotifyGamePauseStateChanged(state);
}

Audio System (legacy)

This is the legacy audio system that made before we started using FMOD.

Features:

  • Allows defining sounds in the context they’re used, e.g. sounds defined in the Main Menu scene won’t be available (and take up the memory) in a gameplay scene.
  • Sounds are referenced by asset reference instead of strings what makes it possible to move and rename audio assets and GameObjects without the need to fix paths.
  • All defined sounds register automatically in the Audio Manager. Sounds can be defined anywhere and they’ll always work.
  • Public API allows to play / pause / stop a sound or all sounds.

Audio system classes:

  • MAudioManager – singleton MonoBehaviour, part of the Game Instance prefab. Accessible to game classes via IAudioManager interface.
  • IAudioManager – defines Audio Manager public API.
  • MSound – MonoBehaviour script paired with AudioSource. Keeps a reference to OSoundId asset.
  • OSoundId – ScriptableObject asset. I servers only as sound identifier. Doesn’t store any data or implement any functionality. Sound ID asset represents a single sound. What sound it is, is defined in the MSound component. Each MSound instance must reference different Sound ID asset.

Usage

Define sounds by creating a GameObject and adding MSound and an AudioSource component to it. Create new OSoundId asset and assign it to the MSound component.

AudioSource is allows for audio playback

Reference OSoundId assets from scripts that you want to play the sound from during gameplay.

MCounterboard references sound ID assets that it wants to be able to play

Play specified sound using Audio Manager API – MGI.Instance.AudioManager.PlaySound(OSoundId).

Here’s the Counterboard playing Final Lap sound on penultimate lap finished:

protected override void World_LapFinished(ELocalPlayerID localPlayerID)
{
	BPlayerState playerState = World.GetActorByID<BPlayerState>(localPlayerID);
	if (playerState.IsPenultimateLap)
	{
		MGI.Instance.AudioManager.PlaySound(View.FinalLapSound);
	}

	View.ClearAllInfoText();
}

Implementation

In Awake(), MSound calls IAudioManager.RegisterSound(OSoundId soundId, MSound sound, bool register = true) and therefore binding the Sound ID asset to the MSound script instance responsible for playing it. Single Sound ID asset can be registered only once.

When PlaySound(OSoundId) is called, Audio Manager finds the MSound binded to the given Sound ID and uses its AudioSource to play the sound.

Script Execution Order

I don’t rely much on the build-in Script Execution Order in Project Settings. I use it only to make sure that any Player Controller (BPlayerController) executes before any other script but after cInput.
First cInput input manager reads the input, the PC is is using cInput to update its input class and then the Pawn is using the input provided by the PC.

Aside from that, ship and camera system scripts control execution order of their components by themselves.

Input

I’m using cInput as the input manager. It simplifies binding keys to actions and makes it possible to rebind keys at runtime.

All key bindings are registered when the game starts in MInputManager script which is part of the Game Instance prefab.

MInputManagerbinds separate actions for gameplay and menus. In gameplay, the player has one set of controls for the keyboard and one for gamepad.
In menus however, there’re two sets of controls. It’s because on the keyboard player may want to use WASD or arrows for horizontal/verical axis and on gamepad he may want to use a D-Pad or a stick.

Menu bindings define two actions for each button (e.g. MENU_KB_SUBMIT for keyboard and MENU_GP_SUBMIT for gamepad) and each of these actions can have assigned primary and secondary input in cInput – which gives 4 input keys for each action.

Menu input bindings:

private static void BindMenuInput()
{
	// Keyboard keys
	cInput.SetKey(SInputAction.MENU_KB_SUBMIT, Keys.Space, Keys.Enter);
	cInput.SetKey(SInputAction.MENU_KB_CANCEL, Keys.Escape);
	cInput.SetKey(SInputAction.MENU_KB_LEFT, Keys.ArrowLeft, Keys.A);
	cInput.SetKey(SInputAction.MENU_KB_RIGHT, Keys.ArrowRight, Keys.D);
	cInput.SetKey(SInputAction.MENU_KB_UP, Keys.ArrowUp, Keys.W);
	cInput.SetKey(SInputAction.MENU_KB_DOWN, Keys.ArrowDown, Keys.S);

	// Gamepad keys
	cInput.SetKey(SInputAction.MENU_GP_SUBMIT, Keys.Xbox1A);
	cInput.SetKey(SInputAction.MENU_GP_CANCEL, Keys.Xbox1Y);
	cInput.SetKey(SInputAction.MENU_GP_LEFT, Keys.Xbox1DPadLeft, Keys.Xbox1LStickLeft);
	cInput.SetKey(SInputAction.MENU_GP_RIGHT, Keys.Xbox1DPadRight, Keys.Xbox1LStickRight);
	cInput.SetKey(SInputAction.MENU_GP_UP, Keys.Xbox1DPadUp, Keys.Xbox1LStickUp);
	cInput.SetKey(SInputAction.MENU_GP_DOWN, Keys.Xbox1DPadDown, Keys.Xbox1LStickDown);

	// Axis
	cInput.SetAxis(SInputAction.MENU_KB_H, SInputAction.MENU_KB_LEFT, SInputAction.MENU_KB_RIGHT);
	cInput.SetAxis(SInputAction.MENU_KB_V, SInputAction.MENU_KB_DOWN, SInputAction.MENU_KB_UP);
	cInput.SetAxis(SInputAction.MENU_GP_H, SInputAction.MENU_GP_LEFT, SInputAction.MENU_GP_RIGHT);
	cInput.SetAxis(SInputAction.MENU_GP_V, SInputAction.MENU_GP_DOWN, SInputAction.MENU_GP_UP);
}

When a menu script wants to know horizontal axis value, it can call IInputManager.GetMenuHorizontalAxis() which checks H-axis input from the keyboard and the gamepad and return the value.

public float GetMenuVerticalAxis()
{
	float menu_kb_v1 = cInput.GetAxisRaw((SInputAction.MENU_KB_V));
	if (Mathf.Abs(menu_kb_v1) > DEADZONE)
	{
		return menu_kb_v1;
	}

	float menu_gp_v1 = cInput.GetAxisRaw((SInputAction.MENU_GP_V));
	if (Mathf.Abs(menu_gp_v1) > DEADZONE)
	{
		return menu_gp_v1;
	}

	return 0;
}

By default, in Unity UI elements like buttons receive input events from a StandaloneInputModule component that itself is using a BaseInput class to read the input. It’s using Unity build-in Input system.

In order to make it use the cInput based input, I made MUIInput (inherits from BaseInput) and registered it in Input Module.

Scene Management

Scene management is done by MSceneManager. Public API available via ISceneManager.

In SSGX scenes can be made up from multiple separate partial scenes. Currently only the gameplay scenes are composite scenes.
This approach allows for easier content editing since assets are separated into multiple scenes and can be edited by multiple people at the same time.

Each scene folder contains a ScriptableObject asset of type OPartialScenes which specifies which partial scenes are part of the Main gameplay scene.

Scene Manager references all the Partial Scenes assets. When a new scene needs to be loaded, it’ll check if there’re any partial scenes defined for the Main scene (the scene to be loaded) and loads them.

Scene loading is implemented as a coroutine. The loading process is as follows:

  • Stop Photon message queue – while new scene is loading, network callbacks should not be executed.
  • Start async load of the Main and Partial scenes.
  • Disable Main scene activation if fade-in is enabled. This prevents cases where the scene finish loading and starts playing while the fade-in UI panel is still animating.
  • Invoke SceneLoadingStarted event.
  • Start fade-in coroutine (if enabled).
  • Update loading screen progress bar while the scenes are loading.
  • Wait until all loaded scenes activate (all scripts run their Start() messages).
  • Start PUN message queue.
  • Trigger CompositeSceneLoaded event if applicable.
  • Enable fade-out (if applicable) and disable loading screen.

Scene loading is initialized by FSM states. FSM is listening to signals from the game code and changes its state base on that. When state changes and it requires a new scene to be loaded, the state calls the Scene Manager to load the scene.

Scene can be loaded by passing a scene name (EMainScene enum) or it can be loaded using Race Settings asset.
When loading gameplay scene, the settings in Race Settings asset (daytime, race direction, track name) define which scene asset needs to be loaded.

Scene Manager prefab contains a Canvas with a loading screen UI. This way, loading screen can be displayed immediately at any point in the game.

ISceneManager interface exposes these events to the game code:

  • Action<EMainScene, LoadSceneMode> SceneLoadingStarted – triggered when a Main scene starts loading.
  • Action<EMainScene> MainSceneActivated – triggered as soon as the main scene is activated. Partial scenes can continue loading even after this event.
  • Action<EMainScene> CompositeSceneLoaded – triggered after the last partial scene of the Main scene finish loading and gets activated. If the loaded scene does not contain any partial scenes, this event won’t be called.
  • Action<bool> LoadingScreenStateChanged – triggered when the loading screen is enabled/disabled.

Scene Transition Fade

While transitioning from one scene to another, it’s possible to enable fade-in and out effect. Fade effects are managed by the Scene Manager and implemented in the MScreenFader component.

MScreenFader component inspector

Debug buttons in the inspector allow to test fading in runtime.

Public API:

  • FadeInDuration : float
  • FadeOutDuration : float
  • KeepOverlay : float – allows to keep the overlay image on the screen after a fade-in.
  • FadingInProgress : bool
  • FadeFinished : Action<EFadeMode> – callback executed after fade end. Passes fade mode as a param (FadeIn, FadeOut, Flash).
  • FadeIn(Action) – starts a fade-in. Executes passed callback when the fade-in ends.
  • FadeOut()
  • Flash() – does a fade in and out in quick succession.
  • HideOverlay() – useful only after a fade-in when KeepOverlay was set to true.
Fade in/out during transition to gameplay scene

Race Track Generation

Race tracks are made in Unity with spline mesh generator. Initially, they were made with Curvy Splines tool but recently I switched to Dreamteck Splines.

Tracks are generated in a separate partial scene that is loaded additively when the main gameplay scene is loaded.

Content of the track generator partial scene

Track generator partial scene contains:

  • Spline – Dreamteck spline component which defines track shape.
  • VisibleMeshGenDreamteck component that takes reference to the spline and a mesh, and extrudes the mesh along the spline.
  • Mesh Colliders (Collision GO) – MeshCollider components (separate for track surface and walls) which reference collider meshes (simplified track meshes) and make colliders out of them.

Currently, meshes used to be extruded along a spline are created in Unity with Curvy in a separate utility scene U_TrackSegment.

Track shape
Track segment generated with Curvy
Cassandra track collision mesh, generated with Dreamteck Splines

Home Stretch

Home Stretch consists of two startlines (FRW and REV), two leaderboards and starting grid. It’s the same for each gameplay scene and therefore it’s a single prefab.
Because of Unity nested prefabs functionality, it’s still possible to make multiple variants that still share common assets and functionality.

PB_HomeStretch prefab hierarchy view
PB_HomeStretch prefab scene view

Counterboard

Counterboard is a screen on the startline that displays the race countdown, animations and other info. Implemented in MCounterboard script.

Counterboard during race countdown

Responsibilities:

  • Play race countdown animation and audio.
  • Play other animations in response to race events.
  • Display race related info, e.g. perfect lap, number of laps left.

Counterboard prefab contains a Checkpoint prefab used for lap counting. This way, 2 of total 3 checkpoints does not have to be manually placed on the track.

Counterboard prefab content and inspector

Countdown sequence is driven by a timeline. It synchronizes the Blocky (SSGX mascot) animation, audio and green lights.

Countdown timeline

Counting Laps

There’re 3 checkpoints (CP) on each track used to count player laps. Player must trigger all 3 checkpoints in the right order to finish a lap.

A checkpoint is a prefab (PB_Checkpoint) that consists of a trigger collider and ACheckpoint Actor script. CP prefab has assigned a Checkpoint tag and belongs to Checkpoint layer.

PB_Checkpoint inspector

Checkpoints are placed at both startlines and in the middle of the track. Each CP must be assigned an index value – one of those 3 positions (Start, Middle, End) in the inspector. CP indexes are automatically reversed for reverse races.

Player ship prefab has a trigger collider (on Checkpoint layer) that works only with checkpoints (it’s set in Unity collision matrix). When the ship triggers a checkpoint, it does the following:

  • Get ACheckpoint component.
  • If CP index is equal to 2 (finish line), call GameMode.NotifyShipCrossedREVStartline().
  • Since the ship tracks which CP should be triggered next (since CPs must be triggered in order), if the CP’s index is not the expected one, ignore this CP. So, if player goes backwards, CPs won’t trigger.
  • Increase next CP index (with wrapping) – index of a CP the the player will trigger next when flying forward.
  • If CP index is equal to 0 (start line) call GameMode.NotifyShipCrossedStartline().
  • Call GameMode.NotifyShipTriggeredCheckpoint().

Here’s how GM reacts to the notifications:

  • NotifyShipCrossedREVStartline() – calls World.NotifyShipCrossedREVStartline().
  • NotifyShipCrossedStartline() – executes only if it was the local ship that crossed the start line. Calls World.NotifyLapFinished(), PlayerState.NotifyShipCrossedStartline() and World.NotifyLapStarted(). Dispatches SessionEndedSignal if session ended (check that info via IPlayerState).
  • NotifyShipTriggeredCheckpoint() – calls GameState.NotifyPlayerTriggeredCheckpoint()and World.NotifyShipTriggeredCheckpoint().

Build System

I made a simple build system where each build type is defined as a separate ScriptableObject asset (build preset). E.g. I can define separate presets for public builds or for testing specific features.

Build preset inspector

Build preset options:

  • Version Number – game version number displayed in the top-left corner of the screen.
  • Scenes – list of scenes that’ll be included in the build. Only included scenes can be loaded at runtime.
  • Description – additional info about the build. Usually the purpose or destination, e.g. (WZ) means that the build was made to be released on the WipEoutZone fourm.
  • Build Type – available options: Test, Dev, Release. Test builds are for testing single or set of feature, include only required scenes. Dev builds include all scenes and game features but includes all debug tools. Relese build is the release version of the game, fully optimized, no debug tools included.
  • Dev Stage – available options: Pre-Alpha, Alpha, Beta, Final Release. Pre-Alpha means that only some features are implemented and mostly in a very basic form. Alpha is when most of the game features and assets are implemented and the game starts resembling the final product. Beta is when the game is basically done but may still require additional balancing and bug fixing.
  • Build Options – Unity build options.
  • Development – Unity option.
  • Symbols – list of pre-compiler symbols to be defined for all source files.
  • Output Dir / File – path for the build executable.

Pressing the Build button in the preset asset inspector calls SBuildScript.Build(OBuildPreset) which does the following:

  • Fill FGameVersion struct with game version data including: game version number, description, build type, development stage, timestamp and build machine. This data is then saved to a JSON file.
  • Update Unity build settings according to build preset settings.
  • Backup Unity define symbols.
  • Set new define symbols.
  • Delete old build directory.
  • Invoke Unity BuildPipeline.BuildPlayer() to create a new build.
  • Restore define symbols.
  • Update static SGameVersion class with JSON file data.

Game Version

Game version is stored in a JSON file in the Resources folder and updated with creation each new build. Version data is updated in SBuildScript and serialization in SGameVersion.

Version data:

  • Version Number – includes: Major, Minor, Revision. Major version changes to denote milestones in the project development. Ver. 0 is the original ssgx, ver. 1 is the project reboot, ver. 2 is the first public build that includes all existing game features (before that, public builds were only tests). Minor version denotes builds that contain new features. Revison builds have only refactorings, balancing changes and bug fixes.
  • Build Type
  • Development Stage
  • Description
  • Timestamp
  • Build Machine

In runtime, access to game version data is provided by SGameVersion.

Leave a comment