Circuit Outlaws is a simple passion project where I aimed to practice the development of a full-stack application/game, while practicing with many different design patterns.
The gameplay of this game is simple: just race through an oval for 3 laps as fast as possible to recieve an entry in the leaderboards.
Everything is realised, from the gameplay to the login system in the front-end that is required to login with to check the leaderboards or start the race, to a full backend that hosts the leaderboard and users. All while making the front-end request and post content through API and SignalR
Augmented Reality was achieved in this project through the usage of the ARFoundation Toolkit within Unity.
GameManager.cs: Singleton pattern to have a Sole instance that manages game logic within the game scene.
///
/// Singleton GameManager manages the state of the race and tracks game logic,
/// like checkpoints, laps, countdown and the race-timer.
///
public class GameManager : MonoBehaviour
{
// Singleton instance
public static GameManager Instance { get; private set; }
// Trackname of current race
private string currentTrackName = "Beta";
public string CurrentTrackName => currentTrackName;
// Countdown-Settings
private int countdownTime = 3;
public int CountdownTime => countdownTime;
// List van all registered checkpoints in de correct order.
[Header("Checkpoints")]
[SerializeField] private List checkpoints = new List();
[SerializeField] private int currentCheckpointIndex = 0;
[SerializeField] private int nextCheckpointIndex = 1;
public List Checkpoints => checkpoints;
// Settings for amount of required laps
[Header("Race Settings")]
[SerializeField] private int lapsRequired;
// Tracking current lap
[Header("Race State")]
[SerializeField] private int currentLap = 1;
public int CurrentLap => currentLap;
[Header("Timer")]
[SerializeField] private float raceTimer = 0;
public float RaceTimer => raceTimer;
public bool LastCheckpointWasReached => nextCheckpointIndex == 0;
public int CurrentCheckpointIndex => currentCheckpointIndex;
// Current state of the game through state pattern
private IRaceState currentState;
public IRaceState State => currentState;
private RaceSubject raceSubject = new RaceSubject();
public RaceSubject RaceSubject => raceSubject;
private CheckpointValidationContext checkpointValidator = new CheckpointValidationContext();
public bool RaceStarted => currentState is RacingState;
public bool RaceFinished => currentState is FinishedState;
public bool IsCountingDown => currentState is CountdownState;
///
/// Wordt aangeroepen voordat Start(). Zorgt ervoor dat slechts één GameManager bestaat.
///
private void Awake()
{
// Singleton-initialisatie
if (Instance == null || Instance.Equals(null))
{
Instance = this;
}
else if (Instance != this)
{
Destroy(gameObject);
return;
}
// Set stragegy for checkpoint validation
checkpointValidator.SetStrategy(new SequentialCheckpointValidationStrategy(), checkpoints);
// React to changing scene
SceneManager.sceneLoaded += SceneLoaded;
}
// Start race upon loading scene
private void SceneLoaded(Scene scene, LoadSceneMode mode)
{
if (scene.name == "RaceScene")
{
Debug.Log("RACE SCENE LOADED");
StartCountdown();
}
}
private void Update()
{
// Only update timer when race is going
if (currentState is RacingState)
raceTimer += Time.deltaTime;
// Update logic for current state (State Pattern)
currentState?.UpdateState(this);
// Inform observers (Observer Pattern)
NotifyRaceUpdate();
}
private void StartCountdown()
{
SetState(new CountdownState());
}
public void StartRace()
{
raceTimer = 0f;
currentLap = 1;
nextCheckpointIndex = 1;
SetState(new RacingState());
}
public void FinishRace()
{
Debug.Log("RACE FINISHED!");
SetState(new FinishedState());
}
public void SetState(IRaceState newState)
{
currentState = newState;
currentState?.EnterState(this);
}
public void ResetRace()
{
SetState(new CountdownState());
raceTimer = 0;
currentLap = 1;
nextCheckpointIndex = 1;
}
// Check if player reaches correct checkpoint
public void OnCheckpointReached(Checkpoint checkpoint)
{
if (checkpointValidator.Validate(checkpoint, nextCheckpointIndex))
{
Debug.Log("Correct Checkpoint reached!");
currentCheckpointIndex = nextCheckpointIndex;
nextCheckpointIndex = (nextCheckpointIndex + 1) % checkpoints.Count;
}
else
{
Debug.Log("Wrong checkpoint!");
}
}
// move to the next lap if player has correctly reached the last checkpoint.
public void OnLapCompleted()
{
currentLap++;
NotifyRaceUpdate();
if (currentLap > lapsRequired)
{
currentLap = lapsRequired;
FinishRace();
}
}
// Collect race-info and notify observers
public void NotifyRaceUpdate()
{
raceSubject.NotifyObservers(new RaceStateData(raceTimer, currentLap, lapsRequired, RaceFinished, CountdownTime));
}
// Coroutine for countdown timer
public IEnumerator CountdownCoroutine()
{
countdownTime = 3;
while (countdownTime > 0)
{
Debug.Log($"CountdownTime: {countdownTime}");
NotifyRaceUpdate();
yield return new WaitForSeconds(1f);
countdownTime--;
}
NotifyRaceUpdate();
StartRace();
}
private void OnDestroy()
{
SceneManager.sceneLoaded -= SceneLoaded;
}
}
SequentialCheckpointValidationStrategy: Strategy to have checkpoints in a sequence and validate them in sequence order.
public class SequentialCheckpointValidationStrategy : ICheckpointValidationStrategy
{
private List checkpoints;
public void Initialize(List checkpoints)
{
this.checkpoints = checkpoints;
}
public bool ValidateCheckpoint(Checkpoint checkpoint, int nextExpectedIndex)
{
return checkpoints != null && checkpoints[nextExpectedIndex] == checkpoint;
}
}
LeaderboardObserver: Observer that submits time to the leaderboard when the race ends.
///
/// Observer that submits time to the leaderbaords when the race ends.
///
public class LeaderboardObserver : MonoBehaviour, IRaceObserver
{
private bool hasSubmittedTime = false;
public void OnRaceUpdated(RaceStateData state)
{
// Only submit time when the race has ended.
if (state.RaceFinished && !hasSubmittedTime)
{
string username = UserManager.Instance.CurrentUser.username;
string trackName = GameManager.Instance.CurrentTrackName;
float time = state.Timer;
// Submit tijd naar backend zodra race is geëindigd
_ = LeaderboardClient.Instance.SubmitTime(trackName, username, time);
hasSubmittedTime = true;
}
}
private void Start()
{
// Register this observer once created
GameManager.Instance.RaceSubject.RegisterObserver(this);
}
private void OnDestroy()
{
// Unregister this observer upon destroying
GameManager.Instance?.RaceSubject?.UnregisterObserver(this);
}
}
IRaceState: State Pattern to determine the state of the game(race).
// Interface voor de different states of the race
public interface IRaceState
{
void EnterState(GameManager gameManager); // gets triggered upon entering state
void UpdateState(GameManager gameManager); // gets triggered every frame upon active state
}