XDR Programming #4: Time Attack mode, Saving leader boards

In this entry, I will cover the Time Attack game mode that I’ve implemented and the saving of leader boards on disc for PC and PlayStation 4 for the game mode.

The rules for the Time Attack mode are described as follows:

  • The player starts the game with a given amount of time in seconds.
  • The timer counts down towards 0. If it reaches 0, it’s game over.
  • The player flies through checkpoint rings. If they successfully fly through a ring, their timer is increased by a set amount of seconds.
  • After a set amount of laps, the race finishes. If the player does not run out of time before completing all of the laps, they win.

This is a well known game mode for arcade games, and can be seen in games such as Crazy Taxi. Further to the core game mode rules, the player would be awarded a Bronze, Silver or Gold trophy upon completion, based on set times needed to win the trophy.

The leader boards to implement with this game mode should present the player with the total time it took them to complete the race and their name. The leader boards would store a set amount of top scores for the given track. If the player performs better than the scores already in the leader board, their score is placed above the worse scores, meaning that the worse scores get pushed down the board and the worst one gets removed.

Time Attack

By the time I started working on this mode, we already had a traditional race mode implemented. This meant that on top of implementing the new mode, I’d have to define a system for picking a game mode. The component that holds all of the meta-data for a particular race is the CGCRaceTracker developed by Radu. The first thing I did was implement a drop-down box to select the game mode in any particular level:

RaceTrackerGameModes.PNG

The idea was to implement Time-Attack specific sub-features while continuing to support the traditional Race mode.

As a first step, I inserted a conditional initialisation of variables particular to the game mode in the initialisation of the RaceTracker:

TAInitialise.PNG

The InitialiseTimeAttack() function is defined as follows:

TAInitialise2.PNG

Here, it’s important to explain what functionality was already implemented by Radu in terms of tracking race meta-data. The RaceTracker is a centralised object keeping track of all meta-data. This includes an array of CheckPointTracker components that every driver has one of. The CheckPointTracker component keeps track of each individual driver’s (be it AI or a player) next checkpoint to go through, checks for going through a checkpoint, and manages the list of checkpoints that define the race.

So while the RaceTracker measures progress of the race overall, the CheckPointTrackers measure the progress of each driver in the race individually. This meant that the appropriate place to keep track of each driver’s timer that goes down is in fact the CheckPointTracker component. This is what the InitialiseTimeAttack() function does – it simply sets up the base start time and the amount of bonus time awarded for going through a checkpoint.

From there on, the CheckPointTracker decrements the time on each Update:

UpdateTA.PNG

Then, when the CheckPointTracker detects a collision with a checkpoint, it increments the time attack timer as such:

TACheckpointBonus.PNG

With the time keeping functionality in place, the RaceTracker component can now manage the whole game mode.

TAUpdatePositions.PNG

The first change to the normal race mode comes when the RaceTracker updates positions for all of the drivers every frame. If a driver finishes the race in TimeAttack mode, the trophy is calculated for the driver and the leader boards are updated with their finish time:

TAFinish.PNG

Now that the win state is taken care of, the last bit to implement is the lose state. If the game mode is set to Time Attack, the following function is called every frame:

TAGameOver.PNG

Here, the RaceTracker goes through all CheckPointTracker components (I simply call them drivers, easier to visualise) and checks their time remaining on the race. If they’ve just run out of time, their controls are disabled and their state is set to EDriverState_GameOver to stop updating the racing behaviour. Some UI text was added by Joe later on to display a “Game Over” message as well when this happens.

Time Attack: What I would do differently

The game mode in itself is simple enough to not require incredible amounts of ingenuity to accomplish. However, the flexibility and extensibility of the code is something that’s always worth critically evaluating, since that’s what causes the code to become unmanageable in the long term.

When approaching the task of this new game mode, I was quite puzzled as to how I should approach it. One certain goal was to make it easy to switch between game modes, and the drop down box definitely achieves that, since it’s easy to just keep adding game modes on top of each other.

As for everything else, I have my reservations. I mainly bolted the Time Attack game mode on top of the existing Race mode with conditionals to define the flow of execution. This was definitely not a big deal for 2 game modes in total, but what if we want to add more in the future?

With 4 or so game modes done this way, it can easily become difficult to keep track of the flow of execution in the code. The main reason I did it this way was to avoid code duplication by creating a separate version of the race tracker for Time Attack. While that goal was achieved, the code did become slightly more complex – the Race Tracker doesn’t have just a single responsibility anymore, it’s multi-tasking! Good OOP design does suggest that when a class starts managing more than one responsibility, it should probably be broken into a separate class to encapsulate these responsibilities.

Recognising the strengths and weaknesses of my approach, I reckon I should have factored out the game mode selection into a separate component with just the drop down box, and then made two separate components for tracking the Time Attack and the Race modes. The Game Mode selection component would contain all of the essential game meta-data tracking functionality that’s common for all game modes, whereas the more specific game mode components would track just the bits that are particular for each game mode. This way, adding new game modes would not complicate existing code, and the new game modes would be completely self-contained, so dependencies would be kept to a minimum. This would be a truly flexible system that’s open for expanding in the future.

I think that’s precisely the approach I will encourage when we are in need of a new game mode, since having more than two contained in one component seems messy.

Saving Leader boards

With the Time Attack game mode in place, it’s time to discuss the next goal of the prototype: saving the scores at the end of the race, and then loading these back up the next time that the same level is loaded. The leader boards themselves are defined as an array of structs (driver name and finish time pairs):

ScoreEntry.PNG

ArrayOfScores.PNG

Once the player successfully finishes a race, the array gets updated:

UpdateLeaderBoard.PNG

This is a simple enough function, where the array is iterated through start to finish and it has two states of execution. The loop iterates from best time to worst, so the first conditional searches for the place to put the new score and inserts it. Then the second conditional gets executed for the rest of the loop, where the remaining scores are shifted down.

Once the leader board is set up, we can examine how the actual saving is done. Unfortunately, this had to be done in the main game application, since saving data is platform specific and the necessary defines are only valid in the main application project. Therefore, it’s worth covering leader board saving on PC, as it’s the more trivial case. In the CGCScene class, a reference to the RaceTracker is fetched at the start of the level:

SceneRaceTrackerInit.PNG

And then it’s queried every frame for whether the race has finished:

SceneUpdate.PNG

This is really wasteful, but since the link between the two projects can only be one-way, we can’t just simply tell the application that the race has finished from the RaceTracker component. A messaging system may alleviate this in the future.

On initialisation, in the case of the PC version, we attempt to load the leader boards:

SceneLoadLeaderPC.PNG

Here, m_pszLevelName is extracted from the level’s file name, and that’s how the save file’s path is constructed. This is how loading leader boards looks on PC:

LoadTimeAttackLeaderboard.PNG

All saving is done into binary files, so we can directly read in the whole save file into our array of leader board scores. If the file isn’t there, we initialise the leader board to default values.

Similarly, for saving on PC:

SaveLeaderOnPC.PNG

The function to save the leader board follows the same logic as loading:

SaveLeadOnPC.PNG

Where it’s just a simple binary write operation. With that in place, we can examine the extra nasty code that is saving for the PlayStation 4. This is done using the PlayStation 4 SDK directly. To begin with, a save directory needs to be created for the specific user before any files can be saved. Since this is mandatory, the application tries to do this on initialisation:

PS4SaveCreate.PNG

I inserted print statements absolutely everywhere, since this is code that interfaces with the PS4 Operating System, we want to trace back anything that goes wrong. I didn’t use asserts in this case, as the whole application crashing might prove less useful for debugging than simple console output.

Firstly, the library for data saving is initialised. Then, we fetch the user ID from the Operating System and attempt to create a save directory titled “SAVEDATA00” (since, currently, we only need one directory at least). The CreateSaveData function looks like this:

PS4CreateSave2.PNG

Here, m_sDirName is a PS4 SDK struct that defines the directory name. Since this is the initialisation stage of the application, we first zero the struct. Then, we print the save name “SAVEDATA00” into it as supplied by the parameter. SetupSaveDataMount is a helper function that simply fills out the data for the SceSaveDataMount struct. We set up mounting the save point in Create mode and attempt to mount the save file. If mounting the save point is successful, it means that the save point was created. There’s one error that we expect to see, a “good” error if you like, which is the one that tells us that the save point already exists. This is expected for all of the cases apart from the first boot of the game.

We can then move on to Loading data upon level start. This is done in the VOnPlay() of CGCScene:

PS4LoadFile1.PNG

PS4LoadFile2.PNG

This is one long and nasty piece of code. Radu already suggested that I at least factor it out into a separate function, and I agree. I’ll discuss code quality at the end of the entry in more depth. For now, here’s what it does. We set up the mount point similarly as before, and if mounting is successful, we can then look for a specific data file. We construct the path for this file as “SAVEDATA00/MindaugasLevel.LEAD” for a level with the name MindaugasLevel.plv. We then create a local array of high scores as the destination for reading in the data. It might be a good idea to read this directly into the RaceTracker’s score array, but in case the data is corrupted, it’s good to refrain from doing so until everything is read in successfully.

We open the file in “Read Only” mode and try to read as much data as is the size of the score array.

If all is successful, we pass the data to the RaceTracker and the values are stored thereon. If at any point this fails, we initialise the default leader board and print out an error.

Saving the data is done similarly, once the end of the race is detected, so I’ll only include the bit that differs:

PS4Saveata.PNG

Here, we set up the file open flags to read-write, over-write and create. This means that any previous data on the file “SAVEDATA00/MindaugasLevel.LEAD” is destroyed and over-written with new scores. The writing is done in bulk as before as a single write command and any errors that occur print out messages to console.

Save leader boards: What would I do differently

I think this feature is the one I’m least happy with out of all that I worked on. I found the PS4 SDK to be quite intimidating to get started with, as it was my first time touching it. Furthermore, it took me way too long to figure out how to do all of this. So without further ado, here’s all that could be better:

Firstly, I do not check for all of the errors that could occur and I don’t take into account really weird situations that might happen. For example, if the save data library fails to initialise on application start, the whole code for saving/writing data will fall over. Thankfully, these failures do not cause any crashes, but ensuring nothing unexpected happens would still be good.

Secondly, and this one is big/important: the way I save data on PC and PS4 is completely different, and that is unnecessarily confusing. This happened because I first implemented a naïve save for PC, also hoping it would potentially work on PS4. Of course, it didn’t work on PS4 and I had to make a separate solution for it. Furthermore, due to the framework’s design, the saving code does not fit well into the RaceTracker, whereas all of PC saving code was put exactly there. And really, when I think about it, it’s not really the RaceTracker’s responsibility to save and load data. Instead, I think that the proper way to refactor this would be to create a separate class in the main application project which is solely responsible for saving and loading data. This should be done as a platform independent abstraction. Better yet, it should support saving and loading all sorts of data, not just specifically leader boards.

So ideally, we would want our Save class to look something like this:

class CGCSaveUtility

{

public:

      ESaveResult saveData(const char* pszFileName, const unsigned int* uSrc, unsigned int uNumBytes);

       ESaveResult loadData(const char* pszFileName, unsigned int* uDest, unsigned int uNumBytes);

}

And the client code, in this particular case, would simply call the function with the file name, the pointer to the leader board array and the number of bytes. The class would automatically figure out the actual path of the save file depending on the platform, and would execute the appropriate code based on the platform we’re running at. No more multi-platform code for very specific scenarios, as we have right now. Just a single, unified interface. This is what the proper solution should be, and it’s what I’d definitely recommend implementing in the near future (a good chance might be when the need to save something different than the Time Attack leader board arises).

Related files

None due to NDA.