Graduate games programmer, currently based in Bristol, England.
Passionate about games as a competitor and developer.
Specialized in building and understanding tools and systems.
Always looking to improve and develop myself, as well as the games I work on.
Looking for opportunities to acquire industry experience, gain new skills, and refine
existing competencies.
5+ years of experience with Unity as a hobbyist and student, with a focus on gameplay programming and
tools development.
Currently in the process of relearning Unreal Engine, and also delving into Godot for the first time.
This page currently contains only projects I am most proud of, ordered by recency.
To view my other projects, please see my github profile.
Click the header of each section to access the
corresponding repository.
Developed in a team of six for our final assignment at university, 'Stitched Up' is a party game where players are tasked with creating and defending their territory in a chaotic battle between souped-up sewing karts.
As the lead gameplay programmer for the project, I created the vehicle physics alongside other core gameplay systems such as input handling, powerups, and combat mechanics.
public static void SuspensionForce(Transform wheel, RaycastHit[] hits,
CarSettings settings, Rigidbody rb)
{
float velocity = Vector3.Dot(rb.GetPointVelocity(wheel.position), wheel.up);
float maxDistance = hits.Max(hit => hit.distance);
foreach (RaycastHit hit in hits)
{
//Spring Compression
float offset = settings.SuspensionRestLength - (hit.distance - settings.RayVerticalOffset);
//Suspension Force = offset * strength - upward vel * dampning
float force = (offset * settings.SuspensionStiffness) - (velocity * settings.SuspensionDamping);
force /= hits.Count(); //Average out force on wheel across all hits
//Apply force to vertical axis, ignoring mass
Vector3 result = force * Vector3.Dot(wheel.up, hit.normal) * wheel.up;
rb.AddForceAtPosition(result, wheel.position, ForceMode.Acceleration);
}
}
public static void ResistanceForce(Transform wheel, Vector3 axis,
float damp, float scalar, Rigidbody rb)
{
//Used for drifting and braking
//Get velocity along axis (right for drift, forward for brake)
float velocity = Vector3.Dot(rb.GetPointVelocity(wheel.position), axis);
//resistance accel (applied in the opposite direction and damped)
//scaled by TireMass for drift, BrakeStrength for brake
float resistance = -velocity * scalar * damp / Time.fixedDeltaTime;
//Apply force to axis
Vector3 result = resistance * axis;
rb.AddForceAtPosition(result, wheel.position);
}
public static void LateralForce(Transform wheel, float input, CarDataTypes.WHEEL_INDEX index,
CarSettings settings, Rigidbody rb)
{
if (input == 0) return; //early out
bool drive = false; //can this wheel produce a driving force?
if (settings.FrontWheelDrive && (index == CarDataTypes.WHEEL_INDEX.FRONT_LEFT ||
index == CarDataTypes.WHEEL_INDEX.FRONT_RIGHT)) drive = true;
else if (settings.RearWheelDrive && (index == CarDataTypes.WHEEL_INDEX.BACK_LEFT ||
index == CarDataTypes.WHEEL_INDEX.BACK_RIGHT)) drive = true;
if (!drive) return; //if not, return
//velocity of car applied to wheel
float velocity = Vector3.Dot(wheel.forward, rb.velocity);
//are we trying to change direction?
bool changingDirection = Mathf.Sign(velocity) != Mathf.Sign(input);
float max = input > 0 ? settings.MaxSpeed : settings.MaxSpeedReverse;
//If we are changing direction, apply resistance force
if (changingDirection) ResistanceForce(wheel, wheel.forward,
settings.TireGrip, settings.BrakeStrength, rb);
float normalVel = Mathf.Clamp01(Mathf.Abs(velocity) / max);
//How much torque is available?
float torque = normalVel == 1 ? 1 : settings.AccelCurve.Evaluate(normalVel);
//resulting force
Vector3 result = torque * input * wheel.forward;
//dampen result based on velocity
float dampening = 1 - Mathf.Clamp01(Mathf.InverseLerp(max * (1 - settings.AccelDampen),
max, Mathf.Abs(velocity)));
//Apply as accel to ignore mass
rb.AddForceAtPosition(dampening * settings.Acceleration * result, wheel.position,
ForceMode.Acceleration);
}
This project was showcased at COMX 2023, where it was played by members of the
public.
Seeing others play our game and enjoy the mechanics and systems I created was a great
experience.
For my final individual university project, I developed a bespoke visual scripting solution for implementing card effects in digital card games. This included developing a custom node editor using Unity's GraphView package and UI Builder, as well as the logic to interpret the graphs and execute effects in-game with events.
public void Populate(CardEffectTree effectTree)
{
if (effectTree == null) return;
ClearElements();
CurrentTree = effectTree;
if (effectTree.nodes == null || !effectTree.nodes.Any()) return;
//Instantiate all nodes
foreach (CardEffectNode node in effectTree.nodes)
{
VisualNode n = new(node, this);
AddElement(n);
n.UpdateDescription();
}
//Connect parents to children
foreach (CardEffectNode parent in effectTree.nodes)
{
if (parent == null) continue;
VisualNode visualParent = GetVisualFromData(parent);
foreach (CardEffectNode child in parent.GetChildren())
{
if (child == null) continue;
child.parent = parent;
VisualNode visualChild = GetVisualFromData(child);
AddElement(visualParent.outputPort.ConnectTo(visualChild.inputPort));
}
}
}
private GraphViewChange OnGraphViewChanged(GraphViewChange graphViewChange)
{
//Remove Elements
if (graphViewChange.elementsToRemove != null)
foreach (GraphElement ge in graphViewChange.elementsToRemove)
if (ge is VisualNode visualNode)
{
CurrentTree.RemoveNode(visualNode.data);
}
else if (ge is Edge edge && edge.output.node is VisualNode parent &&
edge.input.node is VisualNode child)
{
Undo.RecordObject(parent.data, "Remove Child");
parent.data.Remove(child.data);
child.data.parent = null;
EditorUtility.SetDirty(parent.data);
}
//Add Elements
if (graphViewChange.edgesToCreate != null)
foreach (Edge edge in graphViewChange.edgesToCreate)
if (edge.output.node is VisualNode parent && edge.input.node is VisualNode child)
{
Undo.RecordObject(parent.data, "Add Child");
parent.data.Add(child.data);
child.data.parent = parent.data;
EditorUtility.SetDirty(parent.data);
}
//Moved Elements
if (graphViewChange.movedElements != null) CurrentTree.SortTreeByNodeGraphPosition();
//Events
OnGraphViewChange?.Invoke();
CurrentTree.HandleKeywordData();
return graphViewChange;
}
public override void BuildContextualMenu(ContextualMenuPopulateEvent evt)
{
if (CurrentTree == null) return;
TypeCache.TypeCollection types = TypeCache.GetTypesDerivedFrom<CardEffectNode>();
foreach (Type type in types)
{
if (type.IsAbstract) continue; //Don't instantiate abstract types
evt.menu.AppendAction($"{type.BaseType?.Name}/{type.Name}", //create right click menu
_ => //invoke method when item selected
{
MethodInfo method = CreateNodeMethodInfo.MakeGenericMethod(type);
method.Invoke(this, null);
});
}
}
//reflection method info
private static readonly MethodInfo CreateNodeMethodInfo =
typeof(CardEffectGraphView).GetMethod("CreateNode", BindingFlags.NonPublic | BindingFlags.Instance);
//method that gets invoked with the generic type of selected node
private void CreateNode<NodeType>() where NodeType : CardEffectNode
{
NodeType node = CurrentTree.CreateNode<NodeType>();
if (node == null) return;
AddElement(new VisualNode(node, this));
}
This project is a favourite of mine that I am extremely proud of.
An explanation, implementation & demonstration of Rollback Networking within Unity.
The video has had overwhelmingly positive feedback from people looking for resources on the
topic, and I earned 100/100 marks on the assignment.
I'm a huge fan of fighting games and rollback has revolutionized the way they are played online.
Below are the slides from a university presentation created for the graphics programming
module, developed in Unity's HD Render Pipline.
The project contains effects created in HLSL and Shadergraph, with code samples included alongside the
visuals in the presentation.
In the future I would like to explore graphics programming further, including the use of Compute
Shaders.
As part of an Unreal Engine 4 mechanics showcase, I created a custom portal system alongside a character controller. The portals make use of render textures and a lot of math to create an illusion of a portal. They can handle both the player and interactable objects travelling through them.
void APortalManager::UpdatePortalView(APortalActor* Portal)
{
Portal->GetSceneCaptureComponent()->Activate();
APortalActor* LinkedPortal = Portal->GetLinkedPortal();
if (LinkedPortal != nullptr)
{
const UCameraComponent* PlayerCam = PlayerCon->CameraPawn->GetActiveCamera();
USceneCaptureComponent2D* LinkedSceneCapture = LinkedPortal->GetSceneCaptureComponent();
LinkedSceneCapture->Activate();
//get back-facing transform of the portal-to-enter
FTransform ReversePortal = Portal->GetActorTransform();
FRotator Rot = ReversePortal.Rotator();
Rot.Yaw += 180;
ReversePortal.SetRotation(FQuat(Rot));
//set linkedportal's scene capture component transform relative to linkedportal to be the same as the transform between the active camera and the back of the portal-to-enter
//this makes the portal effect work in 3d, instead of just keeping the camera at the exit of the portal, which would make the effect look flat
LinkedSceneCapture->SetRelativeTransform(UKismetMathLibrary::MakeRelativeTransform(PlayerCam->GetComponentTransform(),ReversePortal));
//having the camera be behind the portal, however, means we need to move it's clipping plane forward to make sure it isn't rendering objects between it and it's portal,
//we dont want to player to see those as it breaks the effect.
//these numbers probably need tweaking still but it's close to where i want it to be
LinkedSceneCapture->ClipPlaneNormal = LinkedPortal->GetActorForwardVector();
LinkedSceneCapture->ClipPlaneBase = LinkedPortal->GetActorLocation() - (LinkedSceneCapture->ClipPlaneNormal * ClipBuffer);
}
}
void APortalActor::TeleportActor(AActor* TargetActor)
{
TargetActor->SetActorLocation(PortalUtils::ConvertLocationToLocalSpace(TargetActor->GetActorLocation(), this, LinkedPortal));
FRotator Rot = PortalUtils::ConvertRotationToLocalSpace(TargetActor->GetActorRotation(), this, LinkedPortal);
Rot.Yaw += 180.0f;
TargetActor->SetActorRotation(Rot);
if(TargetActor->IsA(ACharacter::StaticClass()))
{
Rot.Pitch = 0.0f;
Rot.Roll = 0.0f;
const ACharacter* c = static_cast<ACharacter*>(TargetActor);
c->GetController()->SetControlRotation(Rot);
}
//reapply velocity in portal direction
const FVector TeleportVelocity = TargetActor->GetVelocity();
FVector OrientedVelocity;
OrientedVelocity.X = FVector::DotProduct(TeleportVelocity, GetActorForwardVector());
OrientedVelocity.Y = FVector::DotProduct(TeleportVelocity, GetActorRightVector());
OrientedVelocity.Z = FVector::DotProduct(TeleportVelocity, GetActorUpVector());
OrientedVelocity = OrientedVelocity.X * LinkedPortal->GetActorForwardVector() + OrientedVelocity.Y * LinkedPortal->GetActorRightVector() + OrientedVelocity.Z * LinkedPortal->GetActorUpVector();
TargetActor->GetRootComponent()->ComponentVelocity = OrientedVelocity;
LinkedPortal->IsLastPositionInFrontOfPortal = false;
LinkedPortal->UpdateSceneCaptureRenderTarget();
}
FVector PortalUtils::ConvertLocationToLocalSpace(FVector Location, AActor* CurrentSpace, AActor* TargetSpace)
{
if (CurrentSpace == nullptr || TargetSpace == nullptr)
{
return FVector::ZeroVector;
}
const FVector Direction = Location - CurrentSpace->GetActorLocation();
FVector Dots;
Dots.X = FVector::DotProduct(Direction, CurrentSpace->GetActorForwardVector());
Dots.Y = FVector::DotProduct(Direction, CurrentSpace->GetActorRightVector());
Dots.Z = FVector::DotProduct(Direction, CurrentSpace->GetActorUpVector());
FVector NewDirection = Dots.X * TargetSpace->GetActorForwardVector() + Dots.Y * TargetSpace->GetActorRightVector() + Dots.Z * TargetSpace->GetActorUpVector();
NewDirection.Y *= -1;
return TargetSpace->GetActorLocation() + NewDirection;
}
FRotator PortalUtils::ConvertRotationToLocalSpace(FRotator Rotation, AActor* CurrentSpace, AActor* TargetSpace)
{
if (CurrentSpace == nullptr || TargetSpace == nullptr)
{
return FRotator::ZeroRotator;
}
FQuat TargetRotation = TargetSpace->GetTransform().GetRotation();
FQuat InverseSpaceRotation = CurrentSpace->GetTransform().GetRotation().Inverse();
FQuat RotQuat = FQuat(Rotation);
FQuat FinalQuat = TargetRotation * InverseSpaceRotation * RotQuat;
FRotator ReturnRot = FinalQuat.Rotator();
return ReturnRot;
}
Boids implementation within a custom C++ engine created using OpenGL, GLAD, GLFW and Dear ImGui. The engine makes use of a custom Entity-Component system to handle object behaviours, and is optimized using Binary Space Partitioning.
auto Flock::CalculateForce(Transform* a_transform, std::vector* a_neighbourPointer) -> glm::vec3
{
if (a_neighbourPointer == nullptr) return glm::vec3(0); //early out
unsigned int neighbourCount = 0;
glm::vec3 localPos = a_transform->GetRow(POS_VEC);
glm::vec3 fwdVec = a_transform->GetRow(FWD_VEC);
glm::vec3 seperation(0);
glm::vec3 alignment(0);
glm::vec3 cohesion(0);
for (GameObject* other : *a_neighbourPointer)
{
Transform* otherT = other->GetComponent<Transform>().get();
if (otherT == a_transform)
{
continue; //we are looking at ourselves get out early
}
glm::vec3 otherPos = otherT->GetRow(POS_VEC);
glm::vec3 toOther = otherPos - localPos;
if (dot(fwdVec, normalize(toOther)) < 0.7F /* look angle */ && length(toOther) < s_neighbourhoodRaidus)
{ //boid is in our neighbourhood
neighbourCount++;
seperation +=toOther * -1; //toLocal
cohesion +=otherPos;
alignment+=other->GetComponent<BoidBehaviour>()->GetVelocity();
}
}
if (neighbourCount == 0)
{
return glm::vec3(0);
}
for (glm::vec3 v : {seperation, alignment, cohesion})
{
v /= neighbourCount;
if (length(v) != 0.0f)
{
v = normalize(v);
}
}
return seperation + alignment + cohesion; //total our forces
}