🚧 This page is still under construction. Check back soon for updates! 🚧

Coding a perspective-flipping platformer

In this tutorial, I’ll walk you through how I designed, tested, and programmed a platformer game in under 48 hours using the Unity Game Engine.

Table of Contents

Preface and Acknowledgements

Placeholder: Preface notes and acknowledgements for the project.

Unity Game Engine Basics

Placeholder: Introduction to Unity and setting up the project.


using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Player : PhysObject
{
    // Start is called before the first frame update
    new void Start()
    {
        base.Start();
    }

    // Update is called once per frame
    new void Update()
    {
        base.Update();
        transform.rotation = physManager.mainCamera.transform.rotation;
    }

    new void FixedUpdate()
    {
        base.FixedUpdate();
    }
}

Platformer Game Design Challenges

Placeholder: Common challenges in designing platformer gameplay.

Basic Physics and Movement

Placeholder: Core movement, gravity, and collision handling.


public void ApplyGravity()
{
    //There is no gravity during a dash
    if (dashing || justReleasedNovaNode) { return; }

    //Instantiate the gravity magnitude variable
    float gravityMagnitude = gravityCoef * physManager.gravityMagnitude;

    //Set the max fall speed that gravity can apply
    float maxGravityFall = maxFallSpeed;
    if (wallSliding) { maxGravityFall = maxWallFallSpeed; }

    //Increase the force of gravity if upward speed exceed a certain  threshold
    Vector3 verticalVelocityVector = Vector3.ProjectOnPlane(rb.velocity, Right);

    //Only increase the gravity if the velocity is in the upwards direction
    if (Vector3.Dot(verticalVelocityVector, Up) > 0f)
    {
        float verticalSpeed = verticalVelocityVector.magnitude;

        //Only increase the gravity if the speed exceeds a threshold
        if (verticalSpeed >= extraGravityJumpSpeedThreshold)
        {
            //if the speed is faster than the max lerp window then just set the gravity coef to the max
            if (verticalSpeed >= extraGravityJumpSpeedMax) { gravityMagnitude *= extraFrictionMaxCoef; }
            else
            {
                //If the speed is in the lerp window then lerp and add
                float gravityTValue = (verticalSpeed - extraGravityJumpSpeedThreshold) / (extraGravityJumpSpeedMax - extraGravityJumpSpeedThreshold);
                gravityMagnitude *= Mathf.Lerp(extraGravityMinCoef, extraGravityMaxCoef, gravityTValue);
            }
        }
    }

    ApplyForceVectorClampedToPlaneMaxSpeed(Down, Right, maxGravityFall, gravityMagnitude * Time.deltaTime, false);
}

Key Trigonometric Concepts

Placeholder: Applying trigonometry to movement and physics.


public void ApplyForceVectorClampedToPlaneMaxSpeed(Vector3 forceDirection, Vector3 planeNormal, float maxSpeed, float forceMagnitude, bool negateAntagonistic = true, float antagonisticBonusCoef = 1f)
{
    //Get the Vector of just the force relative to the camera and the plane normal
    Vector3 projectedVector = Vector3.ProjectOnPlane(rb.velocity, planeNormal);
    //float projectedVectorMagnitude = projectedVector.magnitude;

    //Determine rather or not the current movement is in the positive direction from the perspective of the camera and the force normal
    bool currentVectorIsPositive = true;
    float projectedDotProduct = Vector3.Dot(projectedVector, forceDirection);
    if (projectedDotProduct < -physManager.dotProductReverseThreshold){ currentVectorIsPositive = false; }

    //Determine if the input is reversing the current movement 
    bool reversing = true;
    if (((forceMagnitude > 0) && (currentVectorIsPositive)) || ((forceMagnitude < 0) && (!currentVectorIsPositive))){ reversing = false; }

    //Determine the change in the velocity
    float velocityDelta = forceMagnitude;
    if (reversing) { velocityDelta *= antagonisticBonusCoef; }

    //Determine if the new velocity will exceed the max movement speed
    Vector3 clampedForce = forceDirection * velocityDelta;
    //Reduce the force based on existing antagonistic forces
    if (negateAntagonistic) { clampedForce = ApplyForceAgainstAntagonisticForces(clampedForce); }
    //Dewtermine what the new velocity would be if the force was actually applied
    Vector3 newVelocity = rb.velocity + clampedForce;
    //Determine what the new velocity vector would be in terms of the given plane normal
    Vector3 newProjectedVector = Vector3.ProjectOnPlane(newVelocity, planeNormal);
    float newProjectedVectorMagnitude = newProjectedVector.magnitude;

    //If the new velocity will exceed the max movement speed then clamp it so that it will only go the max speed
    if (newProjectedVectorMagnitude >= maxSpeed)
    {
        //If the phys object is already moving faster than the max speed in the opposite direction of the force
        //Then do not clamp or adjust the force
        //Only adjust the force if it is congruent with the current movement
        if (Vector3.Dot(newProjectedVector, clampedForce) > 0)
        {
            //Get the vector of the potential max speed
            Vector3 goalProjectedVelocity = forceDirection * maxSpeed;
            if (velocityDelta < 0) { goalProjectedVelocity = -goalProjectedVelocity; }

            //Simply subtract to get a potential new force
            Vector3 potentiallyNewClampedForce = goalProjectedVelocity - projectedVector; 

            //Ensure that the clamping did not reverse the direction of the original force
            if (Vector3.Dot(potentiallyNewClampedForce, clampedForce) <= 0)
            {
                //Reduce the force to nothing if it has to reverse its direction in order to meet the max speed
                clampedForce = new Vector3(0, 0, 0);
            }
            else
            {
                //Use the new clamped force if the force direction was not reversed
                clampedForce = potentiallyNewClampedForce;
            }
        }            
    }

    //Actually Apply The Force
    rb.AddForce(clampedForce, ForceMode.VelocityChange);

    //Debug.DrawLine(transform.position, transform.position + (clampedForce*3), Color.white);
}

Applying Perspective to Inputs and Physics

Placeholder: Using perspective shifts to affect gameplay mechanics.


public void CheckLeftRight()
{
    if ((dashing)||(usingGravityGun)) { return; }

    float movementMagnitude = inputManager.leftRight.value;
    if (Mathf.Abs(movementMagnitude) < inputManager.movementInputMin) { movementMagnitude = 0; }

    //Simulate movement inputs during wall jumps to make them more consistent
    if ((wallJumping)&&(simulateMovementDuringWallJumps))
    {
        if (wallJumpedToTheRight)
        {
            if (((inputManager.inputDirection == Direction.Up) || (inputManager.inputDirection == Direction.RightUp))
                && (inputManager.leftRight.value > -overideSimInputThreshold))
            {
                movementMagnitude = 1;
            }
        }
        else
        {
            if (((inputManager.inputDirection == Direction.Up) || (inputManager.inputDirection == Direction.LeftUp))
                && (inputManager.leftRight.value < overideSimInputThreshold))
            {
                movementMagnitude = -1;
            }
        }
    }

    if (movementMagnitude != 0) 
    {
        if (normalizeMovementInput)
        {
            if (movementMagnitude > 0) { movementMagnitude = 1; }
            else { movementMagnitude = -1; }
        }

        MoveLeftRight(movementMagnitude); 
    }
}

Coyote Time

Placeholder: Adding a buffer window to make jumps more forgiving.


public void InititateReverseCoyoteTime()
{
    inReverseCoyoteTime = true;
    currentReverseCoyoteTime = 0f;
}

public void EndReverseCoyoteTime()
{
    inReverseCoyoteTime = false;
    currentReverseCoyoteTime = 0f;
}

public void TickReverseCoyoteTime()
{
    if (!inReverseCoyoteTime) { return; }

    if (currentReverseCoyoteTime >= reverseCoyoteTime)
    {
        EndReverseCoyoteTime();
        return;
    }

    currentReverseCoyoteTime += Time.deltaTime;
}
        

Wall Jumping

Placeholder: Implementing wall detection and jump mechanics.


public void CheckJump()
{
    if (dashing)
    {
        if (jumping) { EndJump(); }
        if (inputManager.jump.pressed) { jumpAfterDash = true; }
        return;
    }

    //Check if a jump is ending
    if ((!inputManager.jump.held) && (jumping)) { EndJump(); return; }

    //Prioritize jumping off of the ground
    //Check if a jump off the ground is occuring
    if ((inputManager.jump.pressed || inReverseCoyoteTime) && (canJump) && (!jumping)) { Jump(); return; }

    //Check if a jump is currently occuring
    //Prioritize holding a jump after prioritzing a jump starting
    if ((inputManager.jump.held) && (jumping)) 
    {
        if (wallJumping) { HoldWallJump(); return; }
        else { HoldJump(); return; }
    }


    //If no other jump is occuring, check for a wall jump
    if ((inputManager.jump.pressed || inReverseCoyoteTime) && (!wallJumping))
    {
        //Determine which wall to jump off of... if you are near a wall
        bool wallJumpLeftAvailable = false;
        bool wallJumpRightAvailable = false;

        if (nearWallLeft && !leftWallJumpOnCooldown) { wallJumpLeftAvailable = true; }
        if (nearWallRight && !rightWallJumpOnCooldown) { wallJumpRightAvailable = true; }

        //If you are within range to jump off of either the left wall or the right wall then determine which wall to choose
        if ((wallJumpLeftAvailable) && (wallJumpRightAvailable))
        {
            //If you are equally close to both walls, do a tie break
            //A tie break occilates between choosing the left wall and the right wall
            if (collisionDistanceRightWall == collisionDistanceLeftWall)
            {
                if (tieBreakRightWall) { JumpOffWall(true); return; }
                else { JumpOffWall(false); return; }
            }

            //If closer to the left wall then jump off left wall
            if (collisionDistanceLeftWall < collisionDistanceRightWall) { JumpOffWall(false); return; }                
            else { JumpOffWall(true); return; } //If closer to the right wall then jump off right wall
        }

        //If just near the left wall
        if (wallJumpLeftAvailable) { JumpOffWall(false); return; }

        //If just near the right wall
        if (wallJumpRightAvailable) { JumpOffWall(true); return; }

        //If not near the walls, check for coyote times
        //If coyote time is occcuring for both sides simultaneously, then defer to tie break

        wallJumpLeftAvailable = false;
        wallJumpRightAvailable = false;

        if (canWallJumpOffLeftWall && !leftWallJumpOnCooldown) { wallJumpLeftAvailable = true; }
        if (canWallJumpOffRightWall && !rightWallJumpOnCooldown) { wallJumpRightAvailable = true; }

        if ((wallJumpLeftAvailable)&&(wallJumpRightAvailable))
        {
            if (tieBreakRightWall) { JumpOffWall(true); return; }
            else { JumpOffWall(false); return; }
        }

        //If still in coyote time for left wall
        if (wallJumpLeftAvailable) { JumpOffWall(false); return; }

        //If still in coyote time for right wall
        if (wallJumpRightAvailable) { JumpOffWall(true); return; }
    }

    //Initiate reverse coyote time if no other jumps are available
    if (inputManager.jump.pressed) { InititateReverseCoyoteTime(); }
}

Dashing

Placeholder: Creating a dash mechanic with movement bursts and cooldowns.


public void Dash()
{
    //Get the vector of the dash force
    Vector3 dashForce = inputManager.UpDownLeftRight(true);

    //If there is no input direction then default the dash to the direction that the player is facing
    if ((dashForce.x == 0) && (dashForce.y == 0)) 
    {
        if (facing == Direction.Left) { dashForce = Left; }
        else { dashForce = Right; }
    }
    previousDashVector = dashForce;
    //Multiply the normalized vector by the dash force
    dashForce *= initialDashForce;

    //Adjust the x values of the dash force 
    //The x values are adjusted because the dash force is set relative to gravity, with no gravity in the x direction a different value must be used

    //Project the dash force onto the x plane via the up vector
    Vector3 dashForceXOnly = Vector3.ProjectOnPlane(dashForce, Up);
    //Multiply that vector by the dash bonus coeficient
    Vector3 dashXBonusVector = (dashForceXOnly * dashXBonusCoef) - dashForceXOnly;

    //Add that new vector with the x bonus to the original dash force
    dashForce += dashXBonusVector;

    Vector3 dashForceNormalized = dashForce.normalized;

    //Adjust the dash if it is in the downwards direction
    float downwardsMagnitude = Vector3.Dot(dashForceNormalized, Down);
    

    if (downwardsMagnitude > 0.15f)
    {
        if (downwardsMagnitude < 0.8f) { dashForce *= dashDownDiagonalBonusCoef; }
        else { dashForce *= dashDownBonusCoef; }
    }

    InitiateDash();
    //dashSaveVelocity = rb.velocity;
    rb.velocity = new Vector3(0, 0, 0);
    //Debug.Log(dashForce);

    rb.AddForce(dashForce, ForceMode.VelocityChange);
}    

public void InitiateDash()
{
    dashing = true;
    jumpAfterDash = false;
    canDash = false;
    inDashBonus = true;
    currentDashTime = 0f;
}

public void EndDash()
{
    InitiateDashCooldown();
    InitiateDashBonusTime();
    dashing = false;
    //rb.velocity = dashSaveVelocity;
    rb.velocity = rb.velocity * postDashSpeedCoef;
    currentDashTime = 0f;

    if (jumpAfterDash){ inputManager.jump.SpoofInput(1,true);}
    jumpAfterDash = false;
}


public void TickDash()
{
    if (!dashing) { return; }

    if (currentDashTime >= dashTime)
    {
        EndDash();
    }
    currentDashTime += Time.deltaTime;
}


public void InitiateDashCooldown()
{
    dashOnCooldown = true;
    currentDashCooldown = 0f;
}

public void EndDashCooldown()
{
    dashOnCooldown = false;        
    currentDashCooldown = 0f;        
}


public void TickDashCooldown()
{
    if (!dashOnCooldown) { return; }

    if (currentDashCooldown >= dashCooldown)
    {
        EndDashCooldown();
    }
    currentDashCooldown += Time.deltaTime;
}


public void InitiateDashBonusTime()
{
    inDashBonus = true;
    currentDashBonusTime = 0f;
}

public void EndDashBonusTime()
{
    inDashBonus = false;
    currentDashBonusTime = 0f;
}

public void TickDashBonusTime()
{
    if (!inDashBonus) { return; }

    if (currentDashBonusTime >= dashMaxSpeedBonusOvertime)
    {
        EndDashBonusTime();
    }

    currentDashBonusTime += Time.deltaTime;
}

Grappling Hook

Placeholder: Designing and coding a grappling hook ability.


public void GetNearestGravityNode()
{
    nearestGravityNode = physManager.GetNearestNode(transform.position);
}

public bool TryInitiateGravityGunUse()
{
    if (usingGravityGun) { return true; }
    if (nearestGravityNode == null) { return false; }

    Debug.Log("Getting New Node");

    Vector3 vectorToGravityNode = nearestGravityNode.transform.position - transform.position;
    float distance = vectorToGravityNode.magnitude;

    if (distance > nearestGravityNode.range)
    {
        return false;
    }
    else
    {
        grappledGravityNode = nearestGravityNode;
        goalDistanceFromGravityNode = distance;
        enteredNovaNodeThisFrame = true;
        return true;
    }
}

public void ApplyGravityNodeEffects()
{
    if ((!usingGravityGun) || (grappledGravityNode == null)) { return; }

    justReleasedNovaNode = false;

    Vector3 gravityNodeToPlayer = transform.position - grappledGravityNode.transform.position;
    float distanceToPlayer = gravityNodeToPlayer.magnitude;

    //Check if out of range of the gravity gun
    if (distanceToPlayer > grappledGravityNode.range + novaNodeReleaseDistanceBuffer)
    {
        EndGravityGunUse();
        return;
    }
    
    //Get the goal Velocity vector for the swing
    Vector3 goalVelocity = Vector3.ProjectOnPlane(rb.velocity, gravityNodeToPlayer);
    float goalVelocityMagnitude = goalVelocity.magnitude;

            //Initialize the swing if it started this frame
    if (enteredNovaNodeThisFrame)
    {
        float previousVelocityMagnitude = rb.velocity.magnitude;
        novaNodeEnterVelocityMagnitude = previousVelocityMagnitude;
        float magnitudeDif = previousVelocityMagnitude - goalVelocityMagnitude;

        if (magnitudeDif > 0)
        {
            goalVelocityMagnitude += magnitudeDif * novaNodeEnterKeepSpeedCoef;
        }
        enteredNovaNodeThisFrame = false;
    }

    //Add bonus swing based on movement inputs

    
    Vector3 movementInputVector = inputManager.UpDownLeftRight(false, true);
    float movementAcceleration = (novaNodeBaseAcceleration + (novaNodeEnterVelocityMagnitude * novaNodeEnterSpeedBonusAccelerationCoef)) * Time.deltaTime;

    float inputToGoalVeloDot = Vector3.Dot(movementInputVector, goalVelocity);

    if (inputToGoalVeloDot > 0) //Accelerate if analog movement is towards the goal velocity
    {
        goalVelocityMagnitude += movementAcceleration;
    }
    else if (inputToGoalVeloDot < 0)//Otherwise Deccelerate
    {
        goalVelocityMagnitude -= movementAcceleration;
    }
    

    //Recreate the velocity vector
    goalVelocity = goalVelocity.normalized * goalVelocityMagnitude;
    Vector3 grapplingForceVector = goalVelocity - rb.velocity;

    rb.AddForce(grapplingForceVector, ForceMode.VelocityChange);

    //Adjust for swinging in or out

    if (distanceToPlayer != goalDistanceFromGravityNode)
    {
        Vector3 targetLocation = (gravityNodeToPlayer.normalized * goalDistanceFromGravityNode) + grappledGravityNode.transform.position;
        Vector3 adjustmentForce = targetLocation - transform.position;

        float trueDistanceFromTargetLocation = Mathf.Abs(distanceToPlayer - goalDistanceFromGravityNode);
        if ( trueDistanceFromTargetLocation > 0.1f)
        {
            adjustmentForce *= (trueDistanceFromTargetLocation + 1f) * 2 * (rb.velocity.magnitude * Time.deltaTime);
        }

        rb.AddForce(adjustmentForce, ForceMode.VelocityChange);
    }
}

public void InitiateNovaNodeNoAntagonisticCooldown()
{
    justReleasedNovaNode = true;
    currentNovaNodeNoAntagonisticTime = 0f;
}

public void TickNovaNodeNoAntagonisticCooldown()
{
    if (!justReleasedNovaNode) { return; }
    //Debug.DrawLine(transform.position, transform.position + rb.velocity, Color.white);

    if (currentNovaNodeNoAntagonisticTime > novaNodeReleaseNoAntagonisticTime)
    {
        EndNovaNodeNoAntagonisticCooldown();
    }

    currentNovaNodeNoAntagonisticTime += Time.deltaTime;
}

public void EndNovaNodeNoAntagonisticCooldown()
{
    justReleasedNovaNode = false;
    currentNovaNodeNoAntagonisticTime = 0f;
}


public void EndGravityGunUse()
{
    enteredNovaNodeThisFrame = false;
    usingGravityGun = false;
    InitiateNovaNodeNoAntagonisticCooldown();
}

public void DrawGravityGunLine()
{
    lineRenderer.enabled = true;

    if (grappledGravityNode != null)
    {
        Vector3[] newVerticies = new Vector3[2];

        newVerticies[0] = transform.position;
        newVerticies[1] = grappledGravityNode.transform.position;

        lineRenderer.SetPositions(newVerticies);
        lineRenderer.endWidth = 0.18f;
        lineRenderer.startWidth = 0.11f;
    }
}

public void HideGravityGunLine()
{
    lineRenderer.enabled = false;
}

public void CheckGravityGunJump()
{
    //If you have upward momentum, allow a jump
    if (Vector3.Dot(rb.velocity, Up) > 0f)
    {
        NeutralizeCoyoteTime();
        canJump = true;            
        StartCoyoteTime();
    }
}

Play Testing and Refining

Placeholder: Iterating based on testing and player feedback.


public void NegateMomentumInGivenDirection(Vector3 negationDirection)
{
    //Check if any momentum is even going in the negation Direction
    if (Vector3.Dot(negationDirection, rb.velocity) > 0)
    {
        Vector3 newVelocity = Vector3.ProjectOnPlane(rb.velocity, negationDirection);
        rb.velocity = newVelocity;
    }
}

public void NegateDownwardMomentum()
{
    NegateMomentumInGivenDirection(Down);
}

public Vector3 GetAntagonisticForceVectorClampedToPlane(Vector3 forceDirection, Vector3 planeNormal, float antagonisticForceMagnitude, bool noRelatedInput = false, float noRelatedInputBonusCoef = 1f, bool uniDirectional = false)
{
    //Get the Vector of just the movement relative to the camera and the plane normal
    Vector3 projectedVector = Vector3.ProjectOnPlane(rb.velocity, planeNormal);

    //Determine rather or not the current movement is in the positive direction from the perspective of the camera and the force normal
    bool currentVectorIsPositive = true;
    float projectedDotProduct = Vector3.Dot(projectedVector, forceDirection);
    if (projectedDotProduct < -physManager.dotProductReverseThreshold) { currentVectorIsPositive = false; }

    //If the antagonistic force is only allowed to slow in a single direction then exit the function if the movement is in the opposite direction
    if ((uniDirectional) && (currentVectorIsPositive)) { Debug.Log("MovementInOppositeDirection"); return new Vector3(0,0,0); }

    //Determine the change in the velocity
    float velocityDelta = antagonisticForceMagnitude;
    if (noRelatedInput) { velocityDelta *= noRelatedInputBonusCoef; }

    //Determine if the new velocity will exceed the max movement speed
    Vector3 clampedForce = forceDirection * velocityDelta;

    //Reverse the force of the antagonistic vector if the current direction is positive
    if (currentVectorIsPositive) { clampedForce = -clampedForce; }

    Vector3 newVelocity = rb.velocity + clampedForce;
    Vector3 newProjectedVector = Vector3.ProjectOnPlane(newVelocity, planeNormal);

    //Determine rather or not the new movement is in the positive direction from the perspective of the camera and the force normal
    bool newVectorIsPositive = true;
    float newProjectedDotProduct = Vector3.Dot(newProjectedVector, forceDirection);
    if (newProjectedDotProduct < -physManager.dotProductReverseThreshold){  newVectorIsPositive = false; }

    //Determine if the antagonistic force would change the direction of the object.
    //This should not be allowed since antagonistic forces would not change directions of forces, merely slow them

    if (currentVectorIsPositive != newVectorIsPositive)
    {
        //Debug.DrawLine(transform.position, transform.position - (projectedVector), Color.black, 0.2f);
        //Debug.DrawLine(transform.position + new Vector3(0,0.5f,0), transform.position + new Vector3(0, 0.5f, 0) + (clampedForce), Color.red);
        return -projectedVector;            
    }

    //Actually Apply The Force
    //Debug.DrawLine(transform.position, transform.position + (clampedForce*3), Color.red);
    return clampedForce;
}

public void ApplyAntagonisticForceVectorClampedToPlane(Vector3 forceDirection, Vector3 planeNormal, float antagonisticForceMagnitude, bool noRelatedInput = false, float noRelatedInputBonusCoef = 1f, bool uniDirectional = false)
{
    //Get the Vector of just the movement relative to the camera and the plane normal
    Vector3 projectedVector = Vector3.ProjectOnPlane(rb.velocity, planeNormal);

    //Determine rather or not the current movement is in the positive direction from the perspective of the camera and the force normal
    bool currentVectorIsPositive = true;
    float projectedDotProduct = Vector3.Dot(projectedVector, forceDirection);
    if (projectedDotProduct < -physManager.dotProductReverseThreshold){ currentVectorIsPositive = false; }  

    //If the antagonistic force is only allowed to slow in a single direction then exit the function if the movement is in the opposite direction
    if ((uniDirectional) && (currentVectorIsPositive)) { Debug.Log("MovementInOppositeDirection"); return; }

    //Determine the change in the velocity
    float velocityDelta = antagonisticForceMagnitude;
    if (noRelatedInput) { velocityDelta *= noRelatedInputBonusCoef; }

    //Determine if the new velocity will exceed the max movement speed
    Vector3 clampedForce = forceDirection * velocityDelta;

    //Reverse the force of the antagonistic vector if the current direction is positive
    if (currentVectorIsPositive) { clampedForce = -clampedForce; }

    Vector3 newVelocity = rb.velocity + clampedForce;
    Vector3 newProjectedVector = Vector3.ProjectOnPlane(newVelocity, planeNormal);

    //Determine rather or not the new movement is in the positive direction from the perspective of the camera and the force normal
    bool newVectorIsPositive = true;
    float newProjectedDotProduct = Vector3.Dot(newProjectedVector, forceDirection);
    if (newProjectedDotProduct < -physManager.dotProductReverseThreshold){ newVectorIsPositive = false; }

    //Determine if the antagonistic force would change the direction of the object.
    //This should not be allowed since antagonistic forces would not change directions of forces, merely slow them
    if (currentVectorIsPositive != newVectorIsPositive)
    {
        rb.AddForce(-projectedVector, ForceMode.VelocityChange);
        return;
    }

    //Actually Apply The Force
    rb.AddForce(clampedForce, ForceMode.VelocityChange);
}

public void ApplyForceVectorClampedToPlaneMaxSpeed(Vector3 forceDirection, Vector3 planeNormal, float maxSpeed, float forceMagnitude, bool negateAntagonistic = true, float antagonisticBonusCoef = 1f)
{
    //Get the Vector of just the force relative to the camera and the plane normal
    Vector3 projectedVector = Vector3.ProjectOnPlane(rb.velocity, planeNormal);
    //float projectedVectorMagnitude = projectedVector.magnitude;

    //Determine rather or not the current movement is in the positive direction from the perspective of the camera and the force normal
    bool currentVectorIsPositive = true;
    float projectedDotProduct = Vector3.Dot(projectedVector, forceDirection);
    if (projectedDotProduct < -physManager.dotProductReverseThreshold){ currentVectorIsPositive = false; }

    //Determine if the input is reversing the current movement 
    bool reversing = true;
    if (((forceMagnitude > 0) && (currentVectorIsPositive)) || ((forceMagnitude < 0) && (!currentVectorIsPositive))){ reversing = false; }

    //Determine the change in the velocity
    float velocityDelta = forceMagnitude;
    if (reversing) { velocityDelta *= antagonisticBonusCoef; }

    //Determine if the new velocity will exceed the max movement speed
    Vector3 clampedForce = forceDirection * velocityDelta;
    //Reduce the force based on existing antagonistic forces
    if (negateAntagonistic) { clampedForce = ApplyForceAgainstAntagonisticForces(clampedForce); }
    //Dewtermine what the new velocity would be if the force was actually applied
    Vector3 newVelocity = rb.velocity + clampedForce;
    //Determine what the new velocity vector would be in terms of the given plane normal
    Vector3 newProjectedVector = Vector3.ProjectOnPlane(newVelocity, planeNormal);
    float newProjectedVectorMagnitude = newProjectedVector.magnitude;

    //If the new velocity will exceed the max movement speed then clamp it so that it will only go the max speed
    if (newProjectedVectorMagnitude >= maxSpeed)
    {
        //If the phys object is already moving faster than the max speed in the opposite direction of the force
        //Then do not clamp or adjust the force
        //Only adjust the force if it is congruent with the current movement
        if (Vector3.Dot(newProjectedVector, clampedForce) > 0)
        {
            //Get the vector of the potential max speed
            Vector3 goalProjectedVelocity = forceDirection * maxSpeed;
            if (velocityDelta < 0) { goalProjectedVelocity = -goalProjectedVelocity; }

            //Simply subtract to get a potential new force
            Vector3 potentiallyNewClampedForce = goalProjectedVelocity - projectedVector; 

            //Ensure that the clamping did not reverse the direction of the original force
            if (Vector3.Dot(potentiallyNewClampedForce, clampedForce) <= 0)
            {
                //Reduce the force to nothing if it has to reverse its direction in order to meet the max speed
                clampedForce = new Vector3(0, 0, 0);
            }
            else
            {
                //Use the new clamped force if the force direction was not reversed
                clampedForce = potentiallyNewClampedForce;
            }
        }            
    }

    //Actually Apply The Force
    rb.AddForce(clampedForce, ForceMode.VelocityChange);

    //Debug.DrawLine(transform.position, transform.position + (clampedForce*3), Color.white);
}

On the Horizon

Placeholder: Planned features, polish, and future improvements.