CSC8503 Average Heist of the Golden Goose

Video link:

Grade: 100%

Summary

CSC8503 was a module focused on programming physics, networking and AI into a game in C++. The main features of my game were:

Physics

AI

Networking

Other

Extensions of the tutorials

Similarly to CSC8502, some of this was covered in tutorial content, and the main extensions were:

Physics

The codebase for the coursework included some physics code already, and following the tutorial content led to working Axis Aligned Bounding Box, plane, and sphere collisions, with raycast checks for all of these also implemented. The missing collisions were Oriented Bounding Boxes against AABBs, spheres and capsules, and capsule-capsule collisions.

The implementation of capsules was very minimal to start off with, and I managed to implement AABB-Capsule, Sphere-Capsule, OBB-Capsule and Sphere-OBB, but not OBB-Capsule or Capsule-Capsule.

There were some physics features that I was aware were standard, but were missing from the engine. I knew about these features mainly because of their inclusion in the Unity game engine, and I decided to implement them myself, to the best of my knowlege.

For Physics materials, I made a simple struct which stored coefficient of restitution, and a few different damping values for extra control over how physics objects behaved.

struct PhysicsMaterial
{
	float e;
	float linearDampHorizontal;
	float linearDampVertical;
	float angularDamp;
};
PhysicsMaterial struct.

PhysicsObjects in the game store a pointer to a PhysicsMaterial, so when updating the game’s physics, these values can be retrieved and used to modify the result. My main reason for splitting linear damping into vertical and horizontal was to fine tune the player’s controls: by damping more horizontally, the player comes to a stop faster when moving, resulting in controls that feel less slippery. Damping vertical velocity seperately means I could tweak the player’s movement without altering how their jump performed.

Picking Up Objects

In my game, I wanted the player to be able to pick up objects and carry them around, to allow for physics puzzles in my game. This mechanic was very much inspired by the Source engine, and the games made in it such as Half Life 2 and Garry’s mod. To allow the player to pick up objects, there needed to be a way to detect what objects the player could pick up. There were 3 ways I thought to do this:

I opted to go for the third option, as this was the most sensible and would feel the best for the player.

Vector3 ObjectPickupComponent::CalculateLookDirection() {
    float camYaw = camera->GetYaw();
    float camPitch = camera->GetPitch();
	float normYaw = camYaw > 180 ? camYaw - 360 : camYaw; //get yaw between -180 and 180
	normYaw = normYaw * DEGREES_TO_RAD;

	//convert from pitch/yaw to directions
	float xDir = cos(normYaw) * cos(camPitch * DEGREES_TO_RAD);
	float yDir = sin(camPitch * DEGREES_TO_RAD);
	float zDir = sin(-normYaw) * cos(camPitch * DEGREES_TO_RAD);

	return Vector3(zDir, yDir, -xDir);
}
Converting from camera pitch and yaw values into a Vector3 direction. The trigger is then offset by a multiple of this vector.

To make an object “picked up”, a constant force is applied towards the centre of this pickup trigger, which is proportional to distance. Through a simple Hooke’s Law calculation, it was relatively easy to make picking up objects feel good for the player. I wanted it to still feel slightly springy, to convey a feeling of weight in the objects. After some tweaking, I decided on another improvement: changing an object’s PhysicsMaterial while picking it up, and swapping it back afterwards. The reason I did this was because I wanted objects that were picked up to move faster but slow down much quicker, so I increased the force being applied but also increased the damping in a new PhysicsMaterial.

image

The player can pick up and throw objects.

Another physics-based mechanic I added to the game was a grapple hook. For this, I wanted it to behave differently depending on if the player grappled a static wall or a dynamic object. If they grapple a wall, they should be pulled towards it, if they grapple an object, the object should be pulled to them. To do this, I raycast from the player and check the type of the object hit. If its static, then the point the raycast hit is used as the anchor point and the player constantly applies a force towards it. If it is dynamic, a constant force is applied to the centre of the object towards the player.

image

The player can grapple objects towards them.

AI

For the AI of the game, I decided that I should add only 2 enemies: one using a state machine, one using behaviour trees. Both of these techniques were covered in the tutorial content, but not to a degree that resulted in an enemy. For my state machine enemy, I wanted it to behave like a very simple, FPS style enemy: patrol between points until it sees the player - > chase the player until they are close enough - > shoot the player. image

State Machine diagram for the enemy.

The enemy cannot be killed as they have no health and the player has no attack: they are simply an obsticle to avoid. I could have added this ability, but I wanted the enemy to always have presence. A key part to make this enemy function was to integrate pathfinding into the state machine: if the enemy is chasing the player but cannot see them, then they must navigate around walls in the level to see them. Thankfully, my level was all block based, and as such setting nodes in the graph for pathfinding was very easy. Any space that did not have a block was traversable by the enemy. The enemy only performs an A* search if the player has moved at least 1 block away from the enemy’s current goal node, as only then could the player has possibly moved to a new area.

image

State machine enemy chasing and shooting the player.

The behaviour tree enemy is not really an enemy: they are even more of an obsticle than the state machine enemy. They have these behaviours: hover in the air, pull back the treasure, or return to their start point. image

The behaviour tree uses a combination of selectors and sequences.

By having an isTreasureStolen node as the first action in the left sequence, the whole sequence will fail if the treasure has not been stolen, and therefore the root selector will perform the right subtree.

These enemies are very simplistic, but still provided further interest and challenge for the player.

Networking

The networking part of this project was definitely the messiest part, as I created the rest of the game without networking in mind, thinking I would not have the time to get round to it. However, I decided to give it a go anyway, and although it resulted in a codebase I am not very happy with, the result was functional and playable between multiple instances on the same localhost. So far in the coursework, I just had the singleplayer game handle all processing, including physics and AI. However, when moving to networking, I decided to delagate these things to the server instead, as this is the typical approach to make sure the objects on all clients match up in a real time game. For networking, I had a few different types of packets which were used between clients and servers:

struct ClientPacket : public GamePacket {
	int		objectID;
	bool	buttonstates[8];
	//buttonstates[0] = W
	//buttonstates[1] = A
	//buttonstates[2] = S
	//buttonstates[3] = D
	//buttonstates[4] = Space
	//buttonstates[5] = Right Click
	//buttonstates[6] = Left Click
	//buttonstates[7] = E
	float camPitch;
	float camYaw;

	ClientPacket() {
		type = Client_State;
		size = sizeof(ClientPacket) - sizeof(GamePacket);
	}
};
ClientPacket, that sends inputs from client to server.

I store the player inputs in a map structure, with a key of the networkID of the player and the values being a bool[8]. This way, each client’s inputs from the last packet are stored and can be used in game functionality.

void TutorialGame::ProcessClientInput(ClientPacket* p) {

	playerInputsMap[p->objectID][0] = p->buttonstates[0];//w
	playerInputsMap[p->objectID][1] = p->buttonstates[1];//a
	playerInputsMap[p->objectID][2] = p->buttonstates[2];//s
	playerInputsMap[p->objectID][3] = p->buttonstates[3];//d
	playerInputsMap[p->objectID][4] = p->buttonstates[4];//space
	playerInputsMap[p->objectID][5] = p->buttonstates[5];//left click
	playerInputsMap[p->objectID][6] = p->buttonstates[6];//right click
	playerInputsMap[p->objectID][7] = p->buttonstates[7];//e

	playerCameraMap[p->objectID].pitch = p->camPitch;
	playerCameraMap[p->objectID].yaw = p->camYaw;
}
Method to process the data from a client packet. playerInputsMap is used to move the player objects.

With these packets, most of my game functioned the same in multiplayer. There were some missing features however, as if an object is destroyed in the server (such as a door being opened), there is no packet to tell the clients to delete it, and therefore the clients still render the object. I created a DisableObjectPacket, but did not have time to implement the sending and receiving of it.

image

Two clients in the same game.

Simple Components

The initial codebase for the project was not very easy to work with when it came to adding functionality to objects in the game, so I added my own component system. This was a very naive implementation, and was in no way a true Entity Component System as there was no notion of contiguous memory or an entity-component registry. I simply added a new base class, Component.h, with some easy to understand virtual functions:

class Component
{
public:
	Component() { };
	~Component() {};
	Component(GameObject* gameObject) { this->gameObject = gameObject;}
	virtual void Update(float dt) {};
	virtual void PhysicsUpdate(float dt) {};
	virtual void Start(GameWorld* gw) {};
	virtual void OnCollisionBegin(GameObject* otherObject) {};
	virtual void OnCollisionStay(GameObject* otherObject) {};
	virtual void OnCollisionEnd(GameObject* otherObject) {};

	protected:
		GameObject* gameObject;
	};

The heavy use of virtual functions is obviously not the most efficient approach to object functionality, due to the added vtable lookups, but for the purposes of the coursework it provided a very easy way to make objects in my game do things. The methods of the class are heavily inspired by Unity’s MonoBehaviour methods, and I wanted them to be used in basically the exact same way: Update() called every frame, PhysicsUpdate() called every time physics was updated, and so on.

To call these functions, the GameObject class was extended to store a vector of Components. GameObject will iterate through each component and call the appropriate virtual function whenever it is necessary, and as such, each component gets a chance to call their code. Making new functionality involved subclassing Component and overriding the necessary methods, then instantiating it and linking it to a GameObject when the GameObject was created.

What went well

I am quite happy with how my player movement felt, as it behaved very much like how you would expect a first person controller in a normal video game to. My pickup mechanic was also good and was close to how I envisioned it when I thought of the idea. Although my networking was not very impressive and was missing many features, I am still very happy with the extent that it functioned, considering I implemented it in only a few days. I am especially happy with the fact I managed to handle players joining the game for all current players.

What I would improve

My weakest aspect in this module was definitely my implementation of collision detection for the various volumes. My lack of OBB collision detection makes the physics feel very simple, even though it was not required for the game to be playable. Having OBB-Capsule at least allows for the player to run up ramps which is important. Another big area that my physics was lacking was no friction, meaning when objects collided with eachother, they would not rotate. This is something I would have really liked to implement, but I did not want to spend too much time on the physics as, at the time, I still had the AI and networking parts of the coursework to implement.

The gameplay loop was also very weak and simple.

The networked aspect of the game was functional only on a local host, though there was no expectation of the coursework to run across multiple machines. Furthermore, my networking was done using full packets only, instead of the much more efficient delta packets. This was purely a time constraint as I could have figured out how to implement both full and delta packets given a few more days.

Code Samples

StateMachineEnemyComponent::StateMachineEnemyComponent(GameObject* g, std::vector<Vector3> pp) {
	gameObject = g;
	counter = 0.0f;
	stateMachine = new StateMachine();
	patrolPoints = pp;
	State* patrolState = new State([&](float dt)->void {this->Patrol(dt); });
	State* chasePlayerState = new State([&](float dt)->void {this->ChasePlayer(dt); });
	State* shootPlayerState = new State([&](float dt)->void {this->ShootPlayer(dt); });
	State* returnToPatrolState = new State([&](float dt)->void {this->ReturnToPatrol(dt); });

	stateMachine->AddState(patrolState);
	stateMachine->AddState(chasePlayerState);
	stateMachine->AddState(shootPlayerState);

	stateMachine->AddTransition(new StateTransition(patrolState, chasePlayerState, 
		[&]()->bool {
			return canSeePlayer; 
			}));
	stateMachine->AddTransition(new StateTransition(chasePlayerState, shootPlayerState,
		[&]()->bool {
			float distance = (playerObject->GetTransform().GetPosition() - gameObject->GetTransform().GetPosition()).Length();
			return canSeePlayer && distance <= SHOOT_PLAYER_DISTANCE;
			}));
	stateMachine->AddTransition(new StateTransition(shootPlayerState, chasePlayerState,
		[&]()->bool {
			float distance = (playerObject->GetTransform().GetPosition() - gameObject->GetTransform().GetPosition()).Length();
			return !canSeePlayer || distance >= CHASE_PLAYER_DISTANCE;
			}));
	stateMachine->AddTransition(new StateTransition(chasePlayerState, returnToPatrolState, 
		[&]()->bool {
			return !canSeePlayer && losePlayerTimer > CHASE_PLAYER_TIME;
			}));
	stateMachine->AddTransition(new StateTransition(returnToPatrolState, patrolState,
		[&]()->bool {
			float distance = (patrolPoints[currentPatrolPoint] - gameObject->GetTransform().GetPosition()).Length();
			return distance < PATROL_RETURN_DISTANCE;
			}));
	stateMachine->AddTransition(new StateTransition(returnToPatrolState, chasePlayerState,
		[&]()->bool {
			return canSeePlayer;
			}));
}

Because of my component system, making objects interact was relatively easy. Here is the pickup that unlocks the player's grappling hook (UnlockGrappleComponent.cpp):

void UnlockGrappleComponent::OnCollisionBegin(GameObject* other) {
	if (other->GetTag() == "Player") {
		PlayerInputComponent* pic;
		if (other->TryGetComponent<PlayerInputComponent>(pic)) {
			pic->UnlockGrapple();
		}
	}
}

This function is used when the player grapples, and it checks if the grapple hit a static or dynamic object.

void PlayerInputComponent::BeginGrapple()
{
	Vector3 lookDir = CalculateLookDirection();
	Ray ray(gameObject->GetTransform().GetPosition() + Vector3(0, 0.3f, 0), lookDir);
	RayCollision rc;
	if (worldRef->Raycast(ray, rc, true, raycastCollideMap, gameObject)) {
		if (((GameObject*)rc.node)->GetPhysicsObject()->IsDynamic()) {
			grappledObject = (GameObject*)rc.node;
			isGrapplingStatic = false;
		}
		else {
			staticGrapplePoint = rc.collidedAt;
			isGrapplingStatic = true;
		}
		isGrappling = true;
	}
}

The PhysicsUpdate handles any forces in the PlayerInputComponent.

void PlayerInputComponent::PhysicsUpdate(float dt) {
	if (hasJumped) {
		gameObject->GetTransform().SetPosition(gameObject->GetTransform().GetPosition() + Vector3(0, .1f, 0));
		physObject->ApplyLinearImpulse({ 0,jumpPower,0 });
	}
	hasJumped = false;

	if (isGrappling) {
		if (isGrapplingStatic) {
			GrappleStatic();
		}
		else {
			GrappleDynamicObject();
		}
	}
}

These are the functions that handle the forces related to the grappling hook.

void PlayerInputComponent::GrappleStatic()
{
	Debug::DrawLine(gameObject->GetTransform().GetPosition(), staticGrapplePoint);
	if (hasUnlockedGrapple) {
		Vector3 forceDirection = staticGrapplePoint - gameObject->GetTransform().GetPosition();
		forceDirection.Normalise();
		forceDirection.y *= 0.3f; //this helps make the grapple feel more like a swing
		physObject->AddForce(forceDirection * STATIC_FORCE);
	}
	else {
		Debug::Print("Grapple Not Strong Enough!", { 30,80 });
	}
}

void PlayerInputComponent::GrappleDynamicObject()
{
	Debug::DrawLine(grappledObject->GetTransform().GetPosition(), gameObject->GetTransform().GetPosition());
	Vector3 forceDirection = gameObject->GetTransform().GetPosition() - grappledObject->GetTransform().GetPosition();
	forceDirection.Normalise();
	grappledObject->GetPhysicsObject()->SetAwake();
	grappledObject->GetPhysicsObject()->AddForce(forceDirection * DYNAMIC_FORCE);
}

These actions are used for the enem using a behaviour tree.

BehaviourAction* pullTreasure = new BehaviourAction("Pull Treasure", 
	[&](float dt, BehaviourState state)->BehaviourState {
		Vector3 pullDirection = (gameObject->GetTransform().GetPosition() - treasure->GetTransform().GetPosition()).Normalised();
		treasurePhys->SetAwake();
		treasurePhys->AddForce(pullDirection * PULL_TREASURE_AMOUNT *dt);
		return Success;
	}
);

BehaviourAction* isTreasureStolen = new BehaviourAction("Is Treasure Stolen", 
	[&](float dt, BehaviourState state)->BehaviourState {
		if ((treasure->GetTransform().GetPosition() - treasureStartPoint).LengthSquared() > DISTANCE_BEFORE_STOLEN) {
			return Success;
		}
		else {
			return Failure; 
		}
	}
);

BehaviourAction* pulseColours = new BehaviourAction("Pulse Colours", 
	[&](float dt, BehaviourState state)->BehaviourState {
		colourTimer += dt;
		float colourMult = (sin(colourTimer) + 1) * 0.5f;
		Vector4 colour = Debug::RED * colourMult + Debug::BLUE * (1 - colourMult);
		gameObject->GetRenderObject()->SetColour(colour);
		return Success;
	}
);

BehaviourAction* stayAtHome = new BehaviourAction("Stay At Home", 
	[&](float dt, BehaviourState state)->BehaviourState {
		Vector3 direction = (homePoint - gameObject->GetTransform().GetPosition()).Normalised();
		thisPhys->AddForce(direction * PULL_TREASURE_AMOUNT * dt);
		return Success;
	}
);

BehaviourAction* chaseTreasure = new BehaviourAction("Chase Treasure", 
	[&](float dt, BehaviourState state)->BehaviourState {
		if( (homePoint-treasure->GetTransform().GetPosition()).LengthSquared() >CHASE_DISTANCE)
		return Failure;
		Vector3 direction = (treasure->GetTransform().GetPosition() - gameObject->GetTransform().GetPosition()).Normalised();
		thisPhys->SetAwake();
		thisPhys->AddForce(direction * PULL_TREASURE_AMOUNT * dt);
		return Success;
	}
);

For my PhysicsSystem's collision layer system, it references this matrix for if two objects should collide.


enum CollisionLayers
{
	DEFAULT_LAYER = 1, PLAYER_LAYER = 2, STATIC_LAYER = 4, PICKUP_SPHERE_LAYER = 8
};
class PhysicsSystem{
	//...
	std::map<int,bool> layerMatrix =
	{ {DEFAULT_LAYER | DEFAULT_LAYER,true},
		{DEFAULT_LAYER | PLAYER_LAYER,true},
		{DEFAULT_LAYER | STATIC_LAYER,true},
		{DEFAULT_LAYER | PICKUP_SPHERE_LAYER,true},

		{PLAYER_LAYER | PLAYER_LAYER,true},
		{PLAYER_LAYER | STATIC_LAYER,true},
		{PLAYER_LAYER | PICKUP_SPHERE_LAYER,false},

		{STATIC_LAYER | STATIC_LAYER,false},
		{STATIC_LAYER | PICKUP_SPHERE_LAYER,false},

		{PICKUP_SPHERE_LAYER | PICKUP_SPHERE_LAYER,false}
	};
}

These are some networking related functions for handling packets being received.

void TutorialGame::ReceivePacket(int type, GamePacket* payload, int source) {
	if (isClient) {
        ReadPacketClient(type, payload);
	}
	else {
        ReadPacketServer(type, payload, source);
	}
}
void TutorialGame::ReadPacketServer(int type, GamePacket* payload, int source){
    switch (type) {
        case Client_State: {
            ClientPacket* realPacket = (ClientPacket*)payload;
            ProcessClientInput(realPacket);
            break;
        }
        case Player_Connected: { //this means a client is sending a connect message
            ProcessServerPlayerConnectPacket(source, payload);
            break;
        }
    }
}
void TutorialGame::ReadPacketClient(int type, GamePacket* payload)
{
    switch (type) {
        case Full_State: {
            ProcessClientFullPacket(payload);
            break;
        }
        case Player_Connected: { //if we recieve this, it tells us a new player has connected (could be this client)
            ProcessClientPlayerConnectedPacket(payload);
            break;
        }
        case Game_info: {
            ProcessClientGameInfoPacket(payload);
            break;
        }
    }
}

Here are some of the functions used above.

void TutorialGame::ProcessClientPlayerConnectedPacket(GamePacket* payload)
{
    if (!hasClientInitialised) { //this means that this game is a new client and objects for all current players are needed
        PlayerConnectServerAckPacket* realPacket = (PlayerConnectServerAckPacket*)payload;
        AddPlayerToWorld(Vector3(80, 0, 10), true, false, true, realPacket->playerNetIDs[realPacket->numPlayers - 1]);
        for (int i = 0; i < realPacket->numPlayers - 1; i++) {
            AddPlayerToWorld(Vector3(80, 0, 10), true, false, false, realPacket->playerNetIDs[i]);
        }
        hasClientInitialised = true;
    }
    else { //this means a new player has joined the game and a new player object is needed
        PlayerConnectServerAckPacket* realPacket = (PlayerConnectServerAckPacket*)payload;
        AddPlayerToWorld(Vector3(80, 0, 10), true, false, false, realPacket->playerNetIDs[realPacket->numPlayers - 1]);
    }
}

void TutorialGame::ProcessServerPlayerConnectPacket(int source, GamePacket* payload)
{
    if (prevClient == source)return; //if we receive lots of packets from the same client, ignore them and only send one packet back
    PlayerConnectPacket* realPacket = (PlayerConnectPacket*)payload;
    numPlayers++;
    GameObject* player = AddPlayerToWorld(Vector3(80, 0, 10), true, true);
    playerObjects.push_back(player);

    GamePacket* p;
    playerIDs[numPlayers - 1] = currentNetworkObjectID - 1;
    PlayerConnectServerAckPacket* pac = new PlayerConnectServerAckPacket();
    pac->numPlayers = numPlayers;
    pac->playerNetIDs[0] = playerIDs[0];
    pac->playerNetIDs[1] = playerIDs[1];
    pac->playerNetIDs[2] = playerIDs[2];
    pac->playerNetIDs[3] = playerIDs[3];
    server->SendGlobalPacket(*pac); //tell all clients a new player has joined
    prevClient = source;
}

Full codebase can be found at the GitHub page.