Creating a FPS speedrunning game in 8 weeks
Grappling hooks, going fast and responsive movement has never looked more tempting to implement.
For our module before the dissertation, my masters course required us to program a fully fledged video game in about 8 weeks. Apart from being tasked as the team lead of 9 people, I also worked as a Physics, Generalist and Tech Art programmer in the project. This blog aims to highlight all my work on the project as well as some things I feel that could've done better.
Quick Summary of my various contributions
In general, I worked on:
Principal Physics (All collisions between capsules, OBBS and AABBs)
Core Tech Game flow (adding features and level generation sync on both client and server)
Core Player Input and Locomotion
Lava, Fire, UI Shine, and Speedline Shader programming
UI programming (in lua) and rewrite of the main menu
Level Manager, Score Manager, Medal Manager, and other Manager classes
Tools development (Level Loading and Importing)
...and tons of bug fixes.
This article contains a bulk of work I have done, however it is not all of it (for my sanity and yours)
Physics physics physics!
First thing par for the course of a video game, is player locomotion and collision. Since I'd written collision for almost every thing for my physics engine in the last coursework, I decided to use that. I forked my earlier repository, cleaned up a little bit, and made it work with the current project/code base we had on hand.
We decided our player was going to be a capsule, and that our platforms would be require to be rotated, so they would be OBBs. So deciding this earlier meant that I could add support for these early on, and make sure the collisions were working well. Since all our collisions were over the server, I did not need to many adjustments for player vs player collisions.
Read about my OBB code here: Collisions and Quaternions
Game flow and Level Generation...
The second task at hand was to write a data driven level generation system that allows us to load in levels easily from a tool. We decided that using unity as a visual tool to allow people to easily generate levels and have them show up on our project.
As shown on the chart above, we take the level data from the unity scene where we place our cubes, traps, obstacles and such, and then move it to a massive struct that is then exported out to a JSON. Since the more flexibility you give your Designers the better, we essentially take every single detail of object and track it in the JSON. Then the relatively non required things can be sorted out from C++ side. Things like if am object is just client side, or if it's networked or not should be allowed to be toggle able from the unity side.
On the C++ side, I used a lightweight single include library called nlohmann json that allowed me to parse the JSON data. The code file looks a bit heavy since its a lot of raw data processing but here's a snippet of what it looks like.
bool LevelReader::HasReadLevel(const std::string &levelSource) {
std::ifstream jFileStream(Assets::LEVELDIR + levelSource);
if (!jFileStream) {
return false;
}
json jData = json::parse(jFileStream);
startPosition = Vector3(jData["StartPoint"]["x"], jData["StartPoint"]["y"], jData["StartPoint"]["z"] * -1);
endPosition = Vector3(jData["EndPoint"]["x"], jData["EndPoint"]["y"], jData["EndPoint"]["z"] * -1);
deathBoxPosition = Vector3(jData["DeathPlane"]["x"], jData["DeathPlane"]["y"], jData["DeathPlane"]["z"] * -1);
for(auto& item : jData["checkPoints"].items()){
auto temp = Vector3(item.value()["x"], item.value()["y"], item.value()["z"] * -1);
checkPointPositions.emplace_back(temp);
}
for (auto& item : jData["primitiveGameObject"].items()) {
// More data processing
}
//... and.. more data processing
// A few try catching, and finally
return true;
}
This then fills up a few vectors with raw data structs that holds important data for each kind of item in the level. The primitiveGameObject
is the object that's used for platforms, and any other more static parts of the level. There are other structs like oscillatorList
,harmfulOscillatorLists
and more. These all were then read by the level manager instance in the server and the client, and then sent to be used. This then bulids the levels on both the client and the server side.
Player Input and Locomotion
The player input is handled by an InputListener
static class so that it could be called anywhere in the project without having to initialize an instance of the class. Here is the h file to show the type of inputs we accounted for. The player input for the movements for example, was stored in a Vector2 playerInput
sort of like how unity does their axis system.
class InputListener {
public:
InputListener();
~InputListener();
static void InputUpdate();
static Vector2 GetPlayerInput(){ return PlayerInput; }
static float GetJumpInput(){return JumpInput;}
static float GetGrappleInput(){return GrappleInput;}
static bool HasPlayerPressed();
static bool GetDashInput(){ return DashInput; }
}
Player locomotion was standard movement using forces over a dt. The player locomotion's initial code was written by me, and then almost everyone in the team had a hand in it, so the final code ended being an amalgamation of everyone's work. I however wrote a piece of the groundcheck
code that allows us to register if the player is in air or not. This remained pretty unchanged, which looked like this:
bool PlayerMovement::GroundCheck() {
constexpr static float groundOffset = 0.1;
constexpr static float groundDistanceCheck = 0.15;
auto physicsObject = gameObject->GetPhysicsObject();
auto position = gameObject->GetTransform().GetPosition();
auto collVol = (CapsuleVolume*)gameObject->GetBoundingVolume();
Vector3 capBottom = position - Vector3(0, collVol->GetHalfHeight() + collVol->GetRadius(),0);
capBottom += Vector3(0, groundOffset, 0);
Ray ray = Ray(capBottom, Vector3(0,-1,0));
RayCollision closestCollision;
if (world->Raycast(ray, closestCollision, true, gameObject)) {
Vector3 dist = closestCollision.collidedAt - capBottom;
if (dist.Length() < groundDistanceCheck) {
return true;
}
}
return false;
}
Shaders (again...) !
I have written a average size post about the speed lines shader I wrote which you can find here. Other than that, I wrote a fair few more shaders, work of which I will detail down below.
Lava Shader
For the longest time for our "death plane" we used an empty quad with nothing, or a bright orange. Although functionally scary, both nothingness and solid color weren't what we were going for in the aesthetic of our game. So I took a basic foamy texture and using some GLSL transformed it to look like the lava does right now.
A lot of uniforms and basic in and out functions are cut out of this shader to keep it short for the scope of this article.
vec4 uLerp( vec4 a, vec4 b, vec4 t){
return mix(a,b, t);
}
float random(in vec2 uv){
return fract(sin(dot(uv.xy, vec2(12.9898,78.233))) * 43758.5453123);
}
float noise (in vec2 st) {
vec2 i = floor(st);
vec2 f = fract(st);
float a = random(i);
float b = random(i + vec2(1.0, 0.0));
float c = random(i + vec2(0.0, 1.0));
float d = random(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) +
(c - a)* u.y * (1.0 - u.x) +
(d - b) * u.x * u.y;
}
#define OCTAVES 6
float fbm (in vec2 st) {
float value = 0.0;
float amplitude = .5;
float frequency = 0.;
for (int i = 0; i < OCTAVES; i++) {
value += amplitude * noise(st);
st *= 2.;
amplitude *= .5;
}
return value;
}
void main(void) {
vec2 uv = IN.texCoord;
vec2 uvOriginal = uv * 20;
uv *= 100;
vec3 col = vec3(0.0, 0.0, 0.0);
float distortScale = 0.37;
vec2 velocity = 0.1 * vec2(-0.444 * u_time, -0.9 * u_time);
vec2 firstNoise = distortScale * velocity + (uvOriginal);
vec2 distortedTexCoords = vec2(uvOriginal.x * fbm(firstNoise + 0.0001* u_time),uvOriginal.y * fbm(firstNoise + 0.002* u_time));
col += fbm(firstNoise);
col += fbm(firstNoise + 0.5) * 0.5;
col = 1.0-col;
vec4 noiseyTexture = vec4(col, 1.0) + texture(mainTex, distortedTexCoords);
float tintOffset = 1.0;
vec4 tintColorStart = vec4(0.99, 0.35, 0.0, 1.0);
vec4 tintColorEnd = vec4(0.99, 0.0, 0.0, 1.0);
float brightness = 1.0;
vec4 newLavaCol = uLerp(tintColorStart, tintColorEnd, tintOffset* noiseyTexture);
vec4 linetex = texture(mainTex, distortedTexCoords) * newLavaCol;
fragColor = newLavaCol * brightness + linetex ;
}
We first sample the time
and a distortScale
variable to create a new texture coordinates that are dependant on the time an authored value that can be tweaked as per use. We then add some fbm
to it and then add it to itself to add more randomness, giving us our disortedTexCoords
. This adds noise to our texture coordinates, but we need our color to have some variance too. Which we use by adding a few values to the col
variable. Since the FBM is dependent on firstNoise
these color noises will not have any sharp changes.
We then sample the texture, overlay with the distortedTexCoords
, do some blending of colors with the uLerp
function, and then finally multiply it with the brightness to get our final color. We also overlay the texture with the color so that the texture has independent noise ON TOP of the color. This gives us a result as shown below.
Shine Shader
Next thing I looked at, was a our medals. On our final win screen, the player is presented with a medal depending on how fast they finished. Neat! But the images looked rather flat when looked at for more than 5 seconds. So I whipped up a fast shine shader that we could use to make our medals look better.
out vec4 fragColor;
in vec2 TexCoords;
uniform vec4 uiColor;
uniform int hasTexture;
uniform sampler2D tex;
uniform float uTime;
void main() {
vec4 outgoingColor = uiColor;
vec2 uv = TexCoords;
vec4 shineColor = vec4(1.0, 1.0, 1.0 ,0.5);
float shineSpeed = 4.0;
float shineSize = 0.01;
if (hasTexture == 1) {
outgoingColor *= texture(tex, TexCoords);
}
float shine = step(1.0 - shineSize * 0.5, 0.5 + 0.5 * sin(uv.x - uv.y + uTime * shineSpeed));
fragColor = outgoingColor;
fragColor.xyz = mix(fragColor.xyz, shineColor.xyz, shine * shineColor.a );
}
The important line to look at here is where shine
is defined. We use a step function is used to get a solid edge on the shine. The sin(uv.x- uv.y + uTime * shineSpeed)
part of the step function is what actually controls the shine itself. The uv.x-uv.y
is what makes the shine diagonal. We then mix the fragColor
, shineColor
and the shine
itself to give us a clean shine shader on the medals.
More...?
I also reworked the main menu background, and a generative fire sprite to be used as player blips to determine your position in the course. This menu was written in lua by my coursemate, which required me to get used to for a bit. Here is a compilation of the shader work done by me:
Here is a code snippet of the fire shader, and video attached (to keep this article somewhat normal length):
void main() {
vec4 outgoingColor = vec4(0);
vec2 uv= TexCoords;
vec2 Noise1uv = vec2(uv.x, uv.y + uTime * 0.6);
vec4 noise1 = texture(cheeseTexture, Noise1uv);
vec2 Noise2uv = vec2(uv.x, uv.y + uTime *0.6);
vec4 noise2 = texture(cheeseTexture, Noise2uv* 0.5);
vec4 finalnoise = noise1 * noise2;
vec4 mask;
if (hasTexture == 1) {d
mask = texture(tex, TexCoords);
}
vec4 greyscaleFlame = finalnoise + mask;
greyscaleFlame *=mask;
vec4 stepInner = step(0.4, greyscaleFlame);
vec4 stepOpacity = step(0.1, greyscaleFlame);
vec4 outerColor = uiColor;
vec4 outerFlame = outerColor * (1.0-stepInner);
vec4 innerColor = vec4(1.,0.51,0.059,1);
vec4 innerFlame = innerColor * stepInner;
vec4 finalFlame = outerFlame+ innerFlame;
fragColor = vec4(finalFlame.rgb, stepOpacity);
}
Level Loading and Managing
The next thing I did, was effectively abstract the individual LevelReader
, StageTimer
and other level related variables and classes behind an easy to access and use LevelManager
class. This then allows programmers to instance one LevelManager per server and client and then sync them up rather than having to sync up individual objects.
The two most important LevelManager methods are the constructor, and the TryReadLevel
for initializing and reading the levels respectively.
LevelManager::LevelManager() {
levelReader = std::make_unique<LevelReader>();
stageTimer = std::make_unique<StageTimer>();
totalLevels = levelReader->GetNumberOfLevels();
//... more variables...
}
The specific std::make_unique
allows us to create a unique pointer of the class type we pass in. This allows us to use smart pointers, make_unique
is exception safe, as opposed to new
and it allows us to not worry about delete
. Keeping track of what memory you allocate and de-allocate is very important, so for pointers that need to be unique, we use this since its implemented for exception safety.
bool LevelManager::TryReadLevel(std::string levelSource) {
if (!levelReader->HasReadLevel(levelSource + ".json"))
{
std::cerr << "No file available. Check " + Assets::LEVELDIR << std::endl;
return false;
}
medalTimes = levelReader->GetMedalTimes();
stageTimer->SetMedalValues(medalTimes);
return true;
}
The second method we will look at, is TryReadLevel
that will replace the earlier direct levelReader
call. This allows us to populate the stageTimer
with the medal times of individual level reading it out from the JSON.
The stageTimer
class is just a utility class I wrote to store the medal times, and calculate each player's time taken, current position, current time, and other things that are required from a typical timer class.
Debug Class
Half-way through the project, we were experiencing a lot of micro-stutters and low FPS. We did not know if this was a networking bottleneck, a memory one, or a graphics one. Given our course spec also needed a Debug Class, so I decided to write one. The features I ended up adding, and working on, into this debug class were:
FPS count: Pretty standard. This allows us to get a theoretical FPS which we can compare with software like RenderDoc or NSight.
Level Generation & Physics Update: Both these features used my
costClock
which is part of the static class that allows you to callstartCostClock
at the start of a function and thenendCostClock[idx]
that allows us to get the exact time taken by each feature.Current CPU Usage: This allows us to get the CPU used by current process. There is also Physical, virtual memory free, although that wasn't coded in by me.
Collision Count: This tells us the amount of collisions happening at every frame.
Camera and other visual info for rendering and world position usage.
Here is a snippet of code from the Debug Class:
void DebugMode::DisplayCollisionInfo(){
Debug::Print("Collision Count:" + std::to_string(collisionCount), Vector2(0, 30), Debug::WHITE);
}
void DebugMode::StartCostClock(){
start= std::chrono::steady_clock::now();
}
void DebugMode::EndCostClock(int idx){
end = std::chrono::steady_clock::now();
timeValues[idx] = std::chrono::duration<float>(end - start).count();
}
And that is the long and short of it! That was basically the work Ive worked on, I did a lot of other work, like bug fixes and general busywork as well. I also worked on the team morale by making silly meme videos as well.
Things that went well
Overall, I am very happy with the game and how it turned out. Even though we squashed out scope creep pretty early, it also pleasantly surprised me how polished the final build ended up being. Most of the features the team decided upon was met properly, and that made me proud.
I had to constantly clock back into concentration, because of how fun the multiplayer had been. The push towards the first debug build was quite hard since half of our team was missing (they had academic formalities!) , but when all my physics and the networking and level generation came together, I was really happy with how smooth and fun the game felt to play.
I was pleasantly surprised with how much I liked doing the tech art part of the project. I spent a good 2 weeks just writing shaders that helped make the project look good, and feel good. I tweaking the destiny of the speedlines to be just right on the shader, and awaiting what the team thought of it.
"Leading" a team also came with its challenges, but I'd like to think I tackled it pretty good, and the feedback from my teammates had been good.
Things I wish I would've done better
One of my biggest regret from this project is not being able to tackle the PS5. Since we had devkits provided by the university, and I had some prior experience with it, I would've loved to learn how to build to them. However, given our time constraints, we decided to go for a more polished PC build instead. If I could do it again, with more time, I would've volunteered as a core graphics programmer earlier, and would've tried harder to port the graphics onto the ps5.
The level generation tool can be improved as well. Since the rest of the team wanted all the control they could've, the tool code is rather verbose. Since the level generation happens on the main thread and everything else is threaded, there isn't much need for it to be multi-threaded. Functionally the generation is fast enough, However I would've liked to experiment with threaded code, and some c style pre-compiled headers for faster level generation times.
Overall, this project has been a pleasure to work on, and it taught me a lot. Both about code, and my unhealthy dependency on coffee.