
Project P
Project P is a 2D Platformer. It was built as a tech demo rather than a full game. This project was a challenge to myself to further increase my knowledge of scripting and do something I had not done before, to create my own collision system. I also took more time to polish it to make it a solid base, so I could later easily expand further on the project.
Platform:
Engine:
Language:
Development Time:
Team Size:
PC
Unity 5
C#
3 weeks
Solo Project

RESPONSIBILITIES
Everything in the game is made by me, including the Graphics and Animations. The most notable Scripting tasks were:
A Post-Mortem of the project can be found at the bottom of the page.
Player Collisions
Detecting Collisions
Collisions are one of the most important parts of any game, and you as a player only notice them when something wrong happens. With the game being a platformer, and the entire project revolving around making my own collisions, I wanted it to be a reliable and solid system.

The collisions are done by casting multiple Rays. Whenever the Player wants to move in a certain direction, Rays are cast from the corresponding side of the Player’s Collision Box (used to detect triggers). I added an offset inwards called Skin Width and cast the Rays from there, to prevent them from hitting collisions in wrong directions. The Rays’ length is equal to the distance the Player wants to move.
If the Rays hit nothing, then the Player moves the full distance. If the Rays hit an obstacle instead, the Player moves to where the Rays hit.
< CODE: Horizontal Collisions >< CODE: Vertical Collisions >

private void HorizontalCollisions(ref Vector2 moveAmount, bool pushedX) { //Detect if moving up or down (1 or -1) float dirX = Mathf.Sign(moveAmount.x); //Distance we want to move this frame + skinWidth float rayLength = Mathf.Abs(moveAmount.x) + rayCon.skinWidth; //Draw rays in the direction we are going with even distance between them for (int i = 0; i < rayCon.horizontalRayCount; i++) { //Get where the raycasts shall start from Vector2 rayOrigin = (dirX == -1) ? rayCon.raycastOrigins.bottomLeft : rayCon.raycastOrigins.bottomRight; rayOrigin += Vector2.up * (rayCon.horizontalRaySpacing * i); RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.right * dirX, rayLength, rayCon.collisionMask); if (hit) { //Getting the angle of the wall we hit float slopeAngle = Vector2.Angle(hit.normal, Vector2.up); //If the walls angle is less or equal to maxClimbAngle, we will start to climb the slope if (i == 0 && slopeAngle <= maxClimbAngle) { //If we are a bit away from the slope, we "remove" the space between the player and the slope float distanceToSlopeStart = 0; if (slopeAngle != collisions.slopeAngleOld) { distanceToSlopeStart = hit.distance - rayCon.skinWidth; moveAmount.x -= distanceToSlopeStart * dirX; } ClimbSlope(ref moveAmount, slopeAngle); moveAmount.x += distanceToSlopeStart * dirX; } //Only do the first raycast if climbing up a slope if (!collisions.climbingSlope || slopeAngle > maxClimbAngle) { //Velocity.y becomes the distance between raycastorigins and where it hit moveAmount.x = (hit.distance - rayCon.skinWidth) * dirX; //Detect if a object has pushed us into another object if (rayLength > Mathf.Abs(hit.distance) && pushedX) collisions.insideObject = true; //Makes it so the other raycasts can't reach further than what we currently hit, reventing warping into walls at moments rayLength = hit.distance; //Fixes collisions if we hit a wall while climbing a slope if (collisions.climbingSlope) { moveAmount.y = Mathf.Tan(collisions.slopeAngle * Mathf.Deg2Rad * Mathf.Abs(moveAmount.x)); } //Set in what directions we are currently colliding with collisions.left = (dirX == -1); collisions.right = (dirX == 1); } } } }
private void VerticalCollisions(ref Vector2 moveAmount, bool pushedY) { //Detect if moving up or down (1 or -1) float dirY = Mathf.Sign(moveAmount.y); //Distance we want to move this frame + skinWidth float rayLength = Mathf.Abs(moveAmount.y) + rayCon.skinWidth; //Draw rays in the direction we are going with even distance between them for (int i = 0; i < rayCon.verticalRayCount; i++) { //Get where the raycasts shall start from Vector2 rayOrigin = (dirY == -1) ? rayCon.raycastOrigins.bottomLeft : rayCon.raycastOrigins.topLeft; rayOrigin += Vector2.right * (rayCon.verticalRaySpacing * i + moveAmount.x); RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.up * dirY, rayLength, rayCon.collisionMask); if (hit) { //Velocity.y becomes the distance between raycastorigins and where it hit moveAmount.y = (hit.distance - rayCon.skinWidth) * dirY; //Detect if a object has pushed us into another object if (rayLength > Mathf.Abs(hit.distance) && pushedY) collisions.insideObject = true; //Makes it so the other raycasts can't reach further than what we currently hit, reventing warping into walls at moments rayLength = hit.distance; //Fixes collisions if we hit something above the player while climbing a slope if (collisions.climbingSlope) { moveAmount.x = moveAmount.y / Mathf.Tan(collisions.slopeAngle * Mathf.Deg2Rad) * Mathf.Sign(moveAmount.x); } //Set in what directions we are currently colliding with collisions.below = (dirY == -1); collisions.above = (dirY == 1); } }
Walking Along Slopes
Slopes had to be handled differently. If rays are cast horizontally, anything they hit would be detected as walls, stopping the player from moving forward. In the case of slopes though, the player should be able to walk along.
1. To allow walking up slopes, the first Ray that is cast, when moving horizontally, gets the Direction(Normal) of the object it hit. Using the Direction, it then gets an Angle. If the Angle is lower than a set value (45° in this case), then what the Ray hit is considered a walkable Slope. The player’s movement is then altered to move up along the Slope.
2. To allow walking down slopes, a downward Ray is cast from the player’s backside. If the Ray hits anything, it checks the angle of the object it hit, to see if it is a walkable Slope. If it is a Slope and the player is moving down the Slope, the distance between the Rays’ source and the point it hit, is then compared to the Distance the player is moving down this frame. If it is smaller, then we alter the players movement to move down along the Slope.
< CODE: Climb Slope >< CODE: Descend Slope >

private void ClimbSlope(ref Vector2 moveAmount, float slopeAngle) { //Distance we will want to move up the slope float moveDistance = Mathf.Abs(moveAmount.x); //Using trigonomity to calculate what our new velocity.y will be float climbVelocityY = Mathf.Sin(slopeAngle * Mathf.Deg2Rad) * moveDistance; //Detect if the player is jumping/or wants to jump if (moveAmount.y <= climbVelocityY) { //We are not currently jumping so we want to move the player up the slope moveAmount.y = climbVelocityY; //Using trigonomity to calculate what our new velocity.x will be moveAmount.x = Mathf.Cos(slopeAngle * Mathf.Deg2Rad) * moveDistance * Mathf.Sign(moveAmount.x); //As our velocity.y becomes positive, to move up the slope, we are no longer detecting collisions below us, meaning that we are no longer grounded //As we can assume that we are grounded while "climbing" up the slope, we can just directly set it to true collisions.below = true; collisions.climbingSlope = true; collisions.slopeAngle = slopeAngle; } }
private void DescendSlope(ref Vector2 moveAmount) { float dirX = Mathf.Sign(moveAmount.x); Vector2 rayOrigin = (dirX == -1) ? rayCon.raycastOrigins.bottomRight : rayCon.raycastOrigins.bottomLeft; RaycastHit2D hit = Physics2D.Raycast(rayOrigin, -Vector2.up, Mathf.Infinity, rayCon.collisionMask); if (hit) { float slopeAngle = Vector2.Angle(hit.normal, Vector2.up); if(slopeAngle != 0 && slopeAngle <= maxDescendAngle) { //If the normal.x is equal to our dirX, we can conclude that we are moving in the same direction as the slope if(Mathf.Sign(hit.normal.x) == dirX) { //If our distance is less or equal to the distance we have to move on the Y axis, then we are close enough the slope to move along it if(hit.distance - rayCon.skinWidth <= Mathf.Tan(slopeAngle * Mathf.Deg2Rad) * Mathf.Abs(moveAmount.x)) { //Distance we will want to move down the slope float moveDistance = Mathf.Abs(moveAmount.x); //Using trigonomity to calculate what our new velocity.y will be float descendVelocityY = Mathf.Sin(slopeAngle * Mathf.Deg2Rad) * moveDistance; //Using trigonomity to calculate what our new velocity.x will be moveAmount.x = Mathf.Cos(slopeAngle * Mathf.Deg2Rad) * moveDistance * Mathf.Sign(moveAmount.x); moveAmount.y -= descendVelocityY; collisions.slopeAngle = slopeAngle; collisions.descendingSlope = true; collisions.below = true; } } } } }
Moving Platform

Moving Other Objects
Moving platforms use the same components as the player’s collision. They have a Collision Box, Skin Width and cast Rays in the direction they are going. But instead of colliding, they use the Rays to move other objects.

< CODE: Platform Collisions >< CODE: Move Passengers >
If the Rays from the Platform hit an object that can be moved, then the Platforms makes the object move using its own collision. This prevents the Platform from having to calculate the object’s collisions. In order to move objects standing upon the Platform, small Rays are casted upwards. If they hit a movable object, that object will be moved along the Platform.
private void VerticalCollisions(float dirY, Vector2 moveAmount) { float rayLength = Mathf.Abs(moveAmount.y) + rayCon.skinWidth; //Draw rays in the direction we are going with even distance between them for (int i = 0; i < rayCon.verticalRayCount; i++) { //Get where the raycasts shall start from Vector2 rayOrigin = (dirY == -1) ? rayCon.raycastOrigins.bottomLeft : rayCon.raycastOrigins.topLeft; rayOrigin += Vector2.right * (rayCon.verticalRaySpacing * i); RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.up * dirY, rayLength, rayCon.collisionMask); if (hit && !movedPassengers.Contains(hit.transform)) { Vector2 pushAmount = Vector2.zero; pushAmount.x = (dirY == 1) ? moveAmount.x : 0; pushAmount.y = moveAmount.y - (hit.distance - rayCon.skinWidth) * dirY; //If we are moving downwards, we are casting rays down, meaning that whatever we hit is not standing on top of the platform //If we are moving upwards, we are casting rays up, meaning that whatever we hit is standing on top of the platform bool standingOnPlatform = (dirY == 1); passengers.Add(new PassengerMovement(hit.transform, pushAmount, standingOnPlatform, true, false, true)); } } } private void HorizontalCollisions(float dirX, Vector2 moveAmount) { float rayLength = Mathf.Abs(moveAmount.y) + rayCon.skinWidth; //Draw rays in the direction we are going with even distance between them for (int i = 0; i < rayCon.horizontalRayCount; i++) { //Get where the raycasts shall start from Vector2 rayOrigin = (dirX == -1) ? rayCon.raycastOrigins.bottomLeft : rayCon.raycastOrigins.bottomRight; rayOrigin += Vector2.up * (rayCon.horizontalRaySpacing * i); RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.right * dirX, rayLength, rayCon.collisionMask); if (hit && !movedPassengers.Contains(hit.transform)) { Vector2 pushAmount = Vector2.zero; pushAmount.x = moveAmount.x - (hit.distance - rayCon.skinWidth) * dirX; pushAmount.y = -rayCon.skinWidth; passengers.Add(new PassengerMovement(hit.transform, pushAmount, false, true, true, false)); } } } private void OnTopCollisions(Vector2 moveAmount) { float rayLength = rayCon.skinWidth * 2; //Draw rays in the direction we are going with even distance between them for (int i = 0; i < rayCon.verticalRayCount; i++) { //Get where the raycasts shall start from Vector2 rayOrigin = rayCon.raycastOrigins.topLeft + Vector2.right * (rayCon.verticalRaySpacing * i); RaycastHit2D hit = Physics2D.Raycast(rayOrigin, Vector2.up, rayLength, rayCon.collisionMask); if (hit && !movedPassengers.Contains(hit.transform)) { Vector2 pushAmount = Vector2.zero; pushAmount.x = moveAmount.x; pushAmount.y = moveAmount.y; passengers.Add(new PassengerMovement(hit.transform, pushAmount, true, false, false, false)); } } }
private void MovePassengers(bool moveBeforePlatform, Vector2 maxMoveAmount) { foreach (PassengerMovement passenger in passengers) { if (!movedPassengers.Contains(passenger.transform)) { if (!passengerDictionary.ContainsKey(passenger.transform)) { passengerDictionary.Add(passenger.transform, passenger.transform.GetComponent<Controller2D>()); } if (passenger.moveBeforePlatform == moveBeforePlatform) { passengerDictionary[passenger.transform].Move(passenger.velocity, passenger.standingOnPlatform, maxMoveAmount, passenger.pushedX, passenger.pushedY); movedPassengers.Add(passenger.transform); } } } } public struct PassengerMovement { public Transform transform; public Vector2 velocity; public bool standingOnPlatform; public bool moveBeforePlatform; public bool pushedX; public bool pushedY; public PassengerMovement(Transform _transform, Vector2 _velocity, bool _standingOnPlatform, bool _moveBeforePlatform, bool _pushedX, bool _pushedY) { transform = _transform; velocity = _velocity; standingOnPlatform = _standingOnPlatform; moveBeforePlatform = _moveBeforePlatform; pushedX = _pushedX; pushedY = _pushedY; } }

< CODE: Platform Controller>
Platform Movement
To make it easy to adjust and alter the moving platforms path, I made a point system in which you can add, remove and alter the path of the moving platform. The system then allows you to easily decide what speed the platform should have, the amount of easing, wait time between nodes, and if the platform should go back after it reaches the last node or if it should cycle through them.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
using System.Collections; using System.Collections.Generic; using UnityEngine; [RequireComponent(typeof(Pusher2D))] public class PlatformController : MonoBehaviour { [SerializeField] [Range(0f, 10f)] private float speed; [SerializeField] [Range(0f, 5f)] private float waitTime; [SerializeField] [Range(0f, 2f)] private float easeAmount; [SerializeField] private bool cycle; [SerializeField] private Vector2[] localWayPoints; //Values private int fromWaypointIndex; private float percentBetweenWaypoints; private float nextMoveTime; //Vectors private Vector2 velocity; private Vector2 maxVelocity; private Vector2[] globalWayPoints; //References private Pusher2D pusher; private void Awake() { pusher = GetComponent<Pusher2D>(); globalWayPoints = new Vector2[localWayPoints.Length]; for(int i = 0; i < localWayPoints.Length; i++) { globalWayPoints[i] = localWayPoints[i] + (Vector2)transform.position; } } private void FixedUpdate() { velocity = CalculatePlatformMovement(); if (velocity != Vector2.zero) pusher.Move(velocity, maxVelocity); } private Vector2 CalculatePlatformMovement() { if(Time.time < nextMoveTime) { return Vector2.zero; } fromWaypointIndex %= globalWayPoints.Length; int toWaypointIndex = (fromWaypointIndex + 1) % globalWayPoints.Length; float distanceBetweenWaypoints = Vector2.Distance(globalWayPoints[fromWaypointIndex], globalWayPoints[toWaypointIndex]); percentBetweenWaypoints += Time.fixedDeltaTime * speed/distanceBetweenWaypoints; percentBetweenWaypoints = Mathf.Clamp01(percentBetweenWaypoints); float easedPercentBetweenPoints = Ease(percentBetweenWaypoints); Vector2 newPos = Vector2.Lerp(globalWayPoints[fromWaypointIndex], globalWayPoints[toWaypointIndex], easedPercentBetweenPoints); Vector2 centerPos = Vector2.Lerp(globalWayPoints[fromWaypointIndex], globalWayPoints[toWaypointIndex], Ease(0.5f)); Vector2 beforeCenterPos = Vector2.Lerp(globalWayPoints[fromWaypointIndex], globalWayPoints[toWaypointIndex], Ease(0.5f - Time.fixedDeltaTime * speed / distanceBetweenWaypoints)); maxVelocity = centerPos - beforeCenterPos; if (percentBetweenWaypoints >= 1) { percentBetweenWaypoints = 0; fromWaypointIndex++; if((fromWaypointIndex >= globalWayPoints.Length -1) && !cycle) { fromWaypointIndex = 0; System.Array.Reverse(globalWayPoints); } nextMoveTime = Time.time + waitTime; } return (newPos - (Vector2)transform.position); } private float Ease(float x) { float a = easeAmount + 1; return(Mathf.Pow(x, a) / (Mathf.Pow(x,a) + Mathf.Pow(1 - x, a))); } private void OnDrawGizmos() { if(localWayPoints.Length != 0) { float size = 0.3f; for(int i = 0; i < localWayPoints.Length; i++) { Vector2 globalWaypointPos = (Application.isPlaying) ? globalWayPoints[i] : localWayPoints[i] + (Vector2)transform.position; if(localWayPoints.Length > 1) { Gizmos.color = Color.yellow; if(i + 1 < localWayPoints.Length) { Vector2 nextGlobalWaypointPos = (Application.isPlaying) ? globalWayPoints[i + 1] : localWayPoints[i + 1] + (Vector2)transform.position; Gizmos.DrawLine(globalWaypointPos, nextGlobalWaypointPos); } else { if (cycle) { Gizmos.DrawLine(globalWaypointPos, (Application.isPlaying) ? globalWayPoints[0] : localWayPoints[0] + (Vector2)transform.position); } } } Gizmos.color = Color.blue; float modifiedSize = size; if (!cycle) { if(i == 0) { modifiedSize *= 1.5f; Gizmos.color = Color.green; } else if (i + 1 == localWayPoints.Length) { modifiedSize *= 1.5f; Gizmos.color = Color.red; } } else if(i == 0) { modifiedSize *= 1.5f; Gizmos.color = Color.green; } Gizmos.DrawLine(globalWaypointPos - Vector2.up * modifiedSize, globalWaypointPos + Vector2.up * modifiedSize); Gizmos.DrawLine(globalWaypointPos - Vector2.right * modifiedSize, globalWaypointPos + Vector2.right * modifiedSize); } } } } |
Camera Movement
Camera Movement
Platformers usually have cameras that do more than just follow the player directly. So, I wanted to try something special. The result was a camera that moves differently horizontally and vertically. It also behaves differently on both axes, while the player stands on a platform.

Vertical Movement
For the vertical movement, I wanted it to follow the player, but in a smooth way, so as not to be so sharp when you jump. I also wanted the player to have the ability to look up and down. When the player is looking forward, the camera follows the player directly. While looking up or down, it shifts its focus point to be slightly above or below the player.

Vertical Platform Movement
While on a moving platform, I wanted the camera to look in the direction the platform was going. To do this and make sure that it would keep up with the platform, I locked it and interpolated it between two points, one above and one below the player. While the platform was moving, it would interpolate its position depending on how fast the platform was moving, in accordance to its max speed. This would also make it smoothly shift its focus back to the player, when the platform had slowed down.
< CODE: Vertical Movement >

private void VerticalMovement(ref Vector2 movePos) { float playerPosY = player.transform.position.y; float minPos = playerPosY - lockedVerticalLimitDistance; float maxPos = playerPosY + lockedVerticalLimitDistance; if (!player.controller.push.standingOnMovingObject || player.controller.push.pushAmount.y == 0 || player.facingDir != 0) { float targetPos = 0; if (player.facingDir != 0) { if(verticalLookDelay == 1) { targetPos = playerPosY + verticalLimitDistance * player.facingDir; } else { targetPos = playerPosY; verticalLookDelay = Mathf.Clamp01(verticalLookDelay + Time.deltaTime / verticalLookDelayAmount); } } else { targetPos = playerPosY; verticalLookDelay = Mathf.Clamp01(verticalLookDelay - Time.deltaTime / verticalLookDelayAmount); } movePos.y = Mathf.SmoothDamp(transform.position.y, targetPos, ref velocityY, verticalCameraSpeed); lockedVertical = false; verticalCameraSpeedMultiplier = 1; } else { float movinObjectSpeed = player.controller.push.pushAmount.y / player.controller.push.maxPushAmount.y; float lockedCameraYPercentage = (Mathf.Sign(player.controller.push.pushAmount.y) == 1) ? movinObjectSpeed / 2 + 0.5f : 0.5f - movinObjectSpeed / 2; float targetPos = Mathf.Lerp(minPos, maxPos, lockedCameraYPercentage); if (lockedVertical) { movePos.y = targetPos; } else { movePos.y = Mathf.SmoothDamp(transform.position.y, targetPos, ref velocityY, verticalCameraSpeed * verticalCameraSpeedMultiplier); if (Approximately(movePos.y, targetPos, 0.02f)) { lockedVertical = true; } verticalCameraSpeedMultiplier -= Time.fixedDeltaTime; } } } private bool Approximately(float a, float b, float tolerance) { return (Mathf.Abs(a - b) < tolerance); }
Horizontal Movement
For the horizontal movement, I wanted the camera to look ahead of the player. Instead of being floaty like the vertical movement, I locked the camera between two points, one to the right and one to the left of the player. It then interpolates between them similarly like the vertical movement on a moving platform.

While on a moving platform, the same system is used, but it is taken control by the platform’s movement. It also does not shift its focus back to the player when the platforms slow down. This is because you would most likely continue to walk in that direction once you step off the platform.
< CODE: Horizontal Movement >

private void HorizontalMovement(ref Vector2 movePos) { float minPos = player.transform.position.x - lockedHorizontalLimitDistance; float maxPos = player.transform.position.x + lockedHorizontalLimitDistance; if (!player.controller.push.standingOnMovingObject || player.controller.push.pushAmount.x == 0) { float playerWalkingSpeed = Mathf.Abs(player.velocity.x / player.moveSpeed); horizontalLerpPercentag += Time.fixedDeltaTime * lockedHorizontalCameraSpeed * player.walkingDir * playerWalkingSpeed; horizontalLerpPercentag = Mathf.Clamp01(horizontalLerpPercentag); movePos.x = Mathf.Lerp(minPos, maxPos, Mathf.SmoothStep(0, 1, horizontalLerpPercentag)); } else { float movinObjectSpeed = player.controller.push.pushAmount.x / player.controller.push.maxPushAmount.x; horizontalLerpPercentag += Time.fixedDeltaTime * lockedHorizontalCameraSpeed * Mathf.Sign(player.controller.push.pushAmount.x) * movinObjectSpeed; horizontalLerpPercentag = Mathf.Clamp01(horizontalLerpPercentag); movePos.x = Mathf.Lerp(minPos, maxPos, Mathf.SmoothStep(0, 1, horizontalLerpPercentag)); } }
Post-Mortem
This project gave me a deeper understanding of scripting and allowed me to not only make something work, but take a bit of time and polish it thoroughly. It also required me to set up goals and personal milestones for the project.