Cyberdrome
Game Description:
Cyberdrome is a fast-paced, first-person cyberpunk action-arcade game in which the player uses a high-tech sword to compete in an underground fight club where people risk their lives for entertainment and fame.
Development Specification:
- Role: Generalist Programmer/Sound Engineer
- Engine: Unreal Engine 4.19.2
- Development Time: 5 Months
- Team Size: 15
- Number of Programmers: 5
- Published on Steam: Jan 1, 2019
Responsibilities
- Helped architect a modular design for the blueprint classes.
- Refactored and migrated assets from the prototype project (uses UE4 4.17.2) to the production project (uses UE4 4.19.2).
- Built a modular enemy system with interchangeable weapons and body parts (robots).
- Built the functionality for a weight-based enemy spawning weapon, Area of Effect charged blaster weapon and a grenade launcher weapon.
- Integrated VFX assets provided by artists.
- Built a telemetry system that stores gameplay data (json file).
- Built a death visualizer that uses the json output from the telemetry system to visualize the player death locations in the UE4 editor as 3D coordinates.
- Built a save game system that uses the gameplay data from the telemetry system to drive offline leaderboards.
- Recorded (Sound Engineer) voice lines with the help of a voice actor. Created foley effects (Sound Design) for enemies. Created dynamic sound mixes in UE4.
- Integrated footstep sounds and camera bob behavior.
Refactor and Migration of Assets
During the prototype phase all the player functionality was written in a single blueprint actor without thoughts of parallel working since the team had only a single programmer when they began. Once the prototype was approved, more programmers (including me) were assigned to the team and the first task was to refactor all the code and move to a newer version of UE4 (4.19.2). Due to time constraints, completely beginning from scratch for the production project didn’t seem to be a good idea. Instead, the blueprint code base was refactored and the functionality was divided into components to enable smooth parallelism within programmers.
- Created the target folder structure and document it.
- Refactored the player blueprint and divided its functionality into components.
- Communicated with the design and art team to choose the assets that were still needed from the prototype project.
- Migrated the assets that were still needed and resolve the references.
- Data drove the player blueprint using data tables.
Note: The Unreal Engine Blueprint below is interactive. Please feel free to zoom into individual aspects by scrolling the mouse. Hold right mouse and pan to move around. Zoom out to view the entire thing.
Enemies and Weapons
Given the time constraints and number of programmers, we came up with the idea to make the enemies modular such that weapons and body parts could be replaced easily. Since all enemies were robots, this system also helped the artists by decreasing the number of models that had to be manually created. The enemy data table contained the variables like speeds for each state, type of enemy, weapon carried, etc.. This made it easier for the designers to try out different variations and easily increase the variation among enemies.
- Built the Chaser class enemy and its weapon: Area of Effect charged blaster
- Built the Summoner class enemy and its weapon: Weight-based spawner
- Built the grenade launcher for the healer class enemy.
- Integrated VFX assets provided by artists.
- Created foley effects and integrated them.
Chaser
Chaser is an enemy that charges at the player when it’s out of it’s weapon’s range. It has a charged blaster weapon that deals Area of Effect damage. It’s a state based enemy with the following states:
- Alert: Moves towards the player’s position when player is not in line of sight (LoS). If player is in weapon range, transition to attack state. If player is in LoS and out of weapon range transition to wind-up state.
- Attack: Randomly move to location around the player and start the attack behavior on the weapon. Repeat as long as player is in weapon range.
- Wind-up: Prepares to charge when player is in LoS. Starts the wind-up VFX and chooses the location to charge towards. Once, the delay is done it transitions to the charging state.
- Charging: Charges (uninterruptible) towards the player and deals damage with knockback to the player on hit. Turn on the charging VFX on start. Transitions to Cooldown state on reaching destination.
- Cooldown: On completing the charge, the chaser transitions into a cooldown delay. All the charging VFX are turned off in this state.
Chaser Behavior Tree
State Management (Blueprint)
Note: The Unreal Engine Blueprint below is interactive. Please feel free to zoom into individual aspects by scrolling the mouse. Hold right mouse and pan to move around. Zoom out to view the entire thing.
The weapon used by the chaser is a charged blaster that causes Area of effect damage. It has 3 stages: pre-fire delay, attack and cooldown. During it’s pre-fire delay the movement speed of the chaser is reduced. The blueprints for these behaviors are in the tabs below.
Summoner
The summoner is an enemy that spawns a limited number of enemies during its lifetime. This enemy uses a spawning weapon that chooses the enemy to spawn based on weights specified by designers. If the player kills the summoner before it spawns all it’s enemies, the score for the kill is calculated as a multiple of the remaining spawns. This weapon is also a 3 stage weapon that has a pre-fire delay, attack and the cooldown. This enemy uses a common behavior tree created for all enemies except chaser.
Behavior Tree
Note: The Unreal Engine Blueprint below is interactive. Please feel free to zoom into individual aspects by scrolling the mouse. Hold right mouse and pan to move around. Zoom out to view the entire thing.
Healer
The healer is an enemy that provides a large health boost for the player when killed. This enemy uses a grenade launcher and uses the common behavior tree shown in the summoner section. Each grenade has an AoE damage with a knockback. This weapon uses projectile physics to predict the launch angle to attack the player. The launcher is a simple fire and forget weapon with a fire rate to delay individual shots.
Telemetry System and Save System
The telemetry system was made to track playtest metrics and to find ways to improve the game using these metrics. We tracked a long list of metrics that include number of slash kills, dash kills, player death locations, number of enemies when the player died, etc.. This was made pretty easy because we had an event manager actor that contained the registry of all event dispatchers in the game. Binding to most of the events ensured tracking of telemetry data. Once the playthrough of a level is finished, the game writes all the data collected into a json file.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 | #include "BPF_JSONUtils.h" #include <JsonWriter.h> #include <PrettyJsonPrintPolicy.h> #include <JsonSerializer.h> #include <FileHelper.h> #include <Paths.h> #include <Developer/DesktopPlatform/Public/IDesktopPlatform.h> #include <Developer/DesktopPlatform/Public/DesktopPlatformModule.h> #include <SlateApplication.h> //----------------------------------------------------------------------------------------------- // Writes the JSON String to the file // bool UBPF_JSONUtils::WriteJSONObjectToFile(const TSharedPtr<FJsonObject>& JsonObject, const FString& FileName) { FString OutputStr; TSharedRef< TJsonWriter<> > JsonWriter = TJsonWriterFactory<>::Create(&OutputStr); FJsonSerializer::Serialize( JsonObject.ToSharedRef(), JsonWriter ); //GEngine->AddOnScreenDebugMessage(-1, 15.0f, FColor::Red, FPaths::GameDir()); FString FinalPath = FPaths::ProjectDir() + TEXT("Telemetry/") + FileName; return FFileHelper::SaveStringToFile(OutputStr, *FinalPath); } //----------------------------------------------------------------------------------------------- // Tries to get the playthroughs object, if not present returns false. Else true and the object reference is returned // bool UBPF_JSONUtils::TryGetPlaythroughObjArrJSON(TSharedPtr<FJsonObject>& RootObj, TArray<TSharedPtr<FJsonValue>>& PlaysArr, const FString& FileName, bool bIsFinalPath) { FString JsonStr; FString FinalPath = bIsFinalPath ? FileName : FPaths::ProjectDir() + TEXT("Telemetry/") + FileName; bool fileFound = FFileHelper::LoadFileToString(JsonStr, *FinalPath); if(!fileFound) { return false; } TSharedRef< TJsonReader<> > JsonReader = TJsonReaderFactory<>::Create(JsonStr); verifyf(FJsonSerializer::Deserialize(JsonReader, RootObj), TEXT("Bad situation. The first time should have created the object")); PlaysArr = RootObj->GetArrayField("Playthroughs"); return true; } //----------------------------------------------------------------------------------------------- // Creates a json object for the data struct and returns it // TSharedPtr<FJsonObject> UBPF_JSONUtils::MakeJSONObjForData(const FTelemetryData& Data) { TSharedPtr<FJsonObject> JsonObj = MakeShareable(new FJsonObject()); JsonObj->SetStringField("LevelName", Data.LevelName); JsonObj->SetNumberField("Slashes", Data.Slashes); JsonObj->SetNumberField("Dashes", Data.Dashes); JsonObj->SetNumberField("FocusModeTotalTime", Data.FocusModeTotalTime); JsonObj->SetNumberField("Jumps", Data.Jumps); TArray<TSharedPtr<FJsonValue>> DeathArray; for (FTelemetryDeath Death : Data.Deaths) { TSharedPtr<FJsonObject> DeathObj = MakeShareable(new FJsonObject); TArray< TSharedPtr<FJsonValue> > LocationArray; TSharedPtr<FJsonValue> LocationX = MakeShareable(new FJsonValueNumber(Death.Location.X)); LocationArray.Add(LocationX); TSharedPtr<FJsonValue> LocationY = MakeShareable(new FJsonValueNumber(Death.Location.Y)); LocationArray.Add(LocationY); TSharedPtr<FJsonValue> LocationZ = MakeShareable(new FJsonValueNumber(Death.Location.Z)); LocationArray.Add(LocationZ); DeathObj->SetArrayField("Location", LocationArray); DeathObj->SetNumberField("WaveNumber", Death.WaveNumber); DeathObj->SetNumberField("EnemyCount", Death.EnemyCount); DeathObj->SetNumberField("Aggressives", Death.AggressiveCount); DeathObj->SetNumberField("Healer", Death.HealerCount); DeathObj->SetNumberField("Summoner", Death.SummonerCount); DeathObj->SetNumberField("Laser", Death.LaserCount); DeathObj->SetNumberField("Chaser", Death.ChaserCount); TSharedPtr<FJsonValueObject> DeathObjValue = MakeShareable(new FJsonValueObject(DeathObj)); DeathArray.Add(DeathObjValue); } JsonObj->SetArrayField("Deaths", DeathArray); JsonObj->SetNumberField("SlashKills", Data.SlashKills); JsonObj->SetNumberField("DashKills", Data.DashKills); JsonObj->SetNumberField("FocusKills", Data.FocusKills); JsonObj->SetNumberField("DeflectKills", Data.DeflectKills); JsonObj->SetNumberField("AerialKills", Data.AerialKills); JsonObj->SetNumberField("DoubleKills", Data.DoubleKills); JsonObj->SetNumberField("TripleKills", Data.TripleKills); JsonObj->SetNumberField("MultiKills", Data.MultiKills); JsonObj->SetNumberField("TotalDeflectTime", Data.DeflectTotalTimeActive); JsonObj->SetNumberField("EnemiesKilled", Data.EnemyKills); JsonObj->SetNumberField("WaveCount", Data.WaveCount); JsonObj->SetNumberField("TotalTimeInLevel", Data.TotalTimeInLevel); JsonObj->SetNumberField("HighestComboScoreInLevel", Data.HighestComboScoreInLevel); JsonObj->SetNumberField("TotalScoreInLevel", Data.TotalScoreInLevel); TArray<TSharedPtr<FJsonValue>> WaveTimeArray; int WaveNum = 1; for(float WaveTime : Data.TimePerEncounter) { TSharedPtr<FJsonObject> WaveObj = MakeShareable(new FJsonObject()); FString WaveStr = "Wave " + FString::FromInt(WaveNum); WaveObj->SetNumberField(WaveStr, WaveTime); TSharedPtr<FJsonValueObject> WaveObjValue = MakeShareable(new FJsonValueObject(WaveObj)); WaveTimeArray.Add(WaveObjValue); WaveNum++; } JsonObj->SetArrayField("TimesPerWave", WaveTimeArray); return JsonObj; } //----------------------------------------------------------------------------------------------- // Writes the telemetry data to file // bool UBPF_JSONUtils::WriteTelemetryDataToFile(const FTelemetryData& Data, const FString& FileName) { TSharedPtr<FJsonObject> RootObj; TArray<TSharedPtr<FJsonValue>> PlaysArray; // Creates the file if it has to if( !TryGetPlaythroughObjArrJSON(RootObj, PlaysArray, FileName) ) { RootObj = MakeShareable(new FJsonObject); } TSharedPtr<FJsonObject> JsonObj = MakeJSONObjForData(Data); TSharedPtr<FJsonValueObject> PlayValueObj = MakeShareable(new FJsonValueObject(JsonObj)); PlaysArray.Add(PlayValueObj); RootObj->SetArrayField("Playthroughs", PlaysArray); return WriteJSONObjectToFile(RootObj, FileName); } //----------------------------------------------------------------------------------------------- // Loads the JSON File using a file dialog and returns the death locations // bool UBPF_JSONUtils::GetDeathLocationsFromJSONFiles(const FString& LevelName, TArray<FVector>& OutDeathLocations) { FString FileTypes = TEXT("JSON Files (.json)|*.json"); int32 FilterIndex = -1; FString DefaultPath = FPaths::ProjectDir(); FString DialogTitle = TEXT("Open JSON File"); IDesktopPlatform* DesktopPlatform = FDesktopPlatformModule::Get(); bool Opened = false; TArray<FString> OpenFileNames; if(DesktopPlatform) { const void* ParentWindowHandle = FSlateApplication::Get().FindBestParentWindowHandleForDialogs(nullptr); Opened = DesktopPlatform->OpenFileDialog(nullptr, DialogTitle, DefaultPath, TEXT(""), FileTypes, EFileDialogFlags::Multiple, OpenFileNames, FilterIndex); } if(Opened) { for( FString& FileName : OpenFileNames ) { TArray<TSharedPtr<FJsonValue>> DeathObjs; GetDeathObjectsFromFile(FileName, LevelName, DeathObjs); TArray<FVector> DeathLocations; GetDeathLocationsFromDeathObjects(DeathObjs, DeathLocations); OutDeathLocations.Append(DeathLocations); } } return Opened; } //----------------------------------------------------------------------------------------------- // Gets the death objects from a single JSON file // void UBPF_JSONUtils::GetDeathObjectsFromFile(const FString& FileName, const FString& LevelName,TArray<TSharedPtr<FJsonValue>>& OutDeathObjs) { TSharedPtr<FJsonObject> RootObj; TArray<TSharedPtr<FJsonValue>> PlaysArr; TryGetPlaythroughObjArrJSON(RootObj, PlaysArr, FileName, true); for(TSharedPtr<FJsonValue> PlayValue : PlaysArr) { FString PlayLevelName = PlayValue->AsObject()->GetStringField("LevelName"); if(PlayLevelName == LevelName) { TArray<TSharedPtr<FJsonValue>> DeathArr = PlayValue->AsObject()->GetArrayField("Deaths"); OutDeathObjs.Append(DeathArr); } } } //----------------------------------------------------------------------------------------------- // Gets the death locations from the Death objects // void UBPF_JSONUtils::GetDeathLocationsFromDeathObjects(TArray<TSharedPtr<FJsonValue>>& DeathObjs, TArray<FVector>& OutDeathLocations) { for(TSharedPtr<FJsonValue> DeathValue : DeathObjs) { TArray<TSharedPtr<FJsonValue>> LocationValueArr = DeathValue->AsObject()->GetArrayField("Location"); FVector Location; Location.X = LocationValueArr[0]->AsNumber(); Location.Y = LocationValueArr[1]->AsNumber(); Location.Z = LocationValueArr[2]->AsNumber(); OutDeathLocations.Add(Location); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | { "Playthroughs": [ { "LevelName": "PLVL_Tumbler", "Slashes": 372, "Dashes": 56, "FocusModeTotalTime": 1725.921142578125, "Jumps": 527, "Deaths": [ { "Location": [ 5448.2255859375, 850.7850341796875, -2444.8232421875 ], "WaveNumber": 9, "EnemyCount": 24, "Aggressives": 6, "Healer": 0, "Summoner": 3, "Laser": 6, "Chaser": 9 }, { "Location": [ 775.826171875, 1456.669677734375, -4101.435546875 ], "WaveNumber": 9, "EnemyCount": 14, "Aggressives": 0, "Healer": 0, "Summoner": 1, "Laser": 6, "Chaser": 7 } ], "SlashKills": 99, "DashKills": 113, "FocusKills": 0, "DeflectKills": 18, "AerialKills": 60, "DoubleKills": 25, "TripleKills": 6, "MultiKills": 2, "TotalDeflectTime": 651.41693115234375, "EnemiesKilled": 230, "WaveCount": 9, "TotalTimeInLevel": 1178.7725830078125, "HighestComboScoreInLevel": 57750, "TotalScoreInLevel": 740370, "TimesPerWave": [ { "Wave 1": 9.1454381942749023 }, { "Wave 2": 9.1777801513671875 }, { "Wave 3": 29.280948638916016 }, { "Wave 4": 51.108085632324219 }, { "Wave 5": 44.968452453613281 }, { "Wave 6": 162.08370971679688 }, { "Wave 7": 109.82528686523438 }, { "Wave 8": 209.25820922851563 }, { "Wave 9": 515.29791259765625 } ] }, { "LevelName": "PLVL_Tumbler", "Slashes": 0, "Dashes": 0, "FocusModeTotalTime": 0, "Jumps": 0, "Deaths": [], "SlashKills": 0, "DashKills": 0, "FocusKills": 0, "DeflectKills": 0, "AerialKills": 0, "DoubleKills": 0, "TripleKills": 0, "MultiKills": 0, "TotalDeflectTime": 0, "EnemiesKilled": 0, "WaveCount": 0, "TotalTimeInLevel": 2.4618065357208252, "HighestComboScoreInLevel": 0, "TotalScoreInLevel": 0, "TimesPerWave": [] }, { "LevelName": "PLVL_Arena04", "Slashes": 10, "Dashes": 5, "FocusModeTotalTime": 0, "Jumps": 5, "Deaths": [ { "Location": [ -1540.0848388671875, 1799.5914306640625, 148.02186584472656 ], "WaveNumber": 3, "EnemyCount": 5, "Aggressives": 4, "Healer": 1, "Summoner": 0, "Laser": 0, "Chaser": 0 } ], "SlashKills": 6, "DashKills": 3, "FocusKills": 0, "DeflectKills": 16, "AerialKills": 1, "DoubleKills": 0, "TripleKills": 0, "MultiKills": 0, "TotalDeflectTime": 32.063980102539063, "EnemiesKilled": 25, "WaveCount": 3, "TotalTimeInLevel": 63.297382354736328, "HighestComboScoreInLevel": 7900, "TotalScoreInLevel": 13400, "TimesPerWave": [ { "Wave 1": 23.5914306640625 }, { "Wave 2": 23.40315055847168 }, { "Wave 3": 15.660858154296875 } ] } ] } |
Using the data generated by the telemetry system, I created a death visualizer that runs in the UE4 editor. This tool reads the death locations from the selected json files and places cubes in the world to represent death locations. This was used by designers to iterate on their levels.
Initially, this data was only being tracked for gameplay iterations. Later into development we decided to display this data at the end of the level to show how well players did in each level. I implemented a save game system that stores this data sorted by total score per level. This data was used to rank the player for each level and this rank is displayed in the level select screen on the offline leaderboard.
Personal Postmortem
- Worked across a variety of systems and thus ended up with a broader understanding of UE4.
- Refactoring code written by other developers with time constraints was a good exercise.
- Thinking about design consequences was a great way to cut ideas that may not fit the scope.
- Got to understand UE4 C++ integration and its hurdles when working with other programmers on the team.
- Coding AI behaviors took a longer time than expected.
- Using time constants to drive animation events led to constant code changes when animation clips changed.
- Estimate more time on unfamiliar tasks (AI) to account for cases that are not thought-off initially.
- Animation driven actions/events are better supported by Animation Notifies than time constants.
Team Postmortem
- First game we shipped on Steam!
- Great pivot/turnaround mid-development to new genre (linear story to arena-arcade game).
- Team maintained positive attitude despite developmental hurdles.
- Open communication about issues, decentralized QA.
- Team was patient and productive whenever project/repository was in transition/lockdown mode.
- Crunch until last few milestones.
- Performance was almost always an afterthought.
- Communication hiccups caused cuts and re-work.
- Documentation was not maintained after halfway through the project.
- AI and Animations add significant scope to a game project.
- Performance should be a decentralized responsibility.
- Receive frequent feedback on work from team leads to make sure everyone is on the same page.
- Documentation should be a part of the relevant tasks’ estimated time.