Engineer based in Royal Leamington Spa.
Passionate about games as a competitor and developer.
Specialized in building and refining tools and systems.
Always looking to improve and develop myself, as well as the games I work on.
Currently working on Forza Horizon 6 as a Tools & Systems Engineer.
2 years experience as a AAA developer.
To view Source Code for personal projects, check out my Github.
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 enjoyed by members of the public. Maybe I'll get the band back together and finish this someday, it's quite fun!
For the better part of 2 years, I have been working at Playground Games to deliver the next installation in the acclaimed Forza Horizon series! Working as a Tools Engineer allowed me to gain experience writing industry-quality C# and C++; collaborating with content teams to deliver the robust, high quality tooling required to fuel AAA games. On the Systems side, I worked to ensure the stability of the ForzaTech engine for both developers and players.
Driven by inconsistent card wording in HearthStone, 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));
}
An explanation, implementation & demonstration of Rollback Networking with C#, using Unity as a display engine.
The video has had overwhelmingly positive feedback from people looking for resources on the
topic, which are unfortunately scarce.
I love fighting games, and rollback has revolutionized the way they are played online.
As part of an Unreal Engine 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 the illusion of a portal with depth. 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;
}