A physics based flight simulator and first person explorer mini game

Duration 6 weeks
Tools Unity Engine, Blender, Krita, Unity add-ons (Probuilder, Gaia, Unity Shader Graph)
Summary One of my goals with this project was to take a deep dive into the different aspects of physics in Unity since game physics has always fascinated me. When testing out my ideas with people, I realized that what everyone seemed to enjoy the most was the flight simulator. So I decided to design the world and create exploration elements from a flying perspective instead of the other way around. This evolved into a flight simulator-first person explorer-mini game. I created this project to explore physics-based scripting in Unity, crafting modular kits and to design with Probuilder in Unity.

Level design

My idea was to create an open world game with short stories to complete at your own pace. This was designed with my older brothers in mind since they’re always busy and rarely have time for gaming with me. To travel around, choosing between missions and exit whenever you like suits their active lifestyles.

A lot of effort went into the flying mechanics because I wanted just flying around to be enough to enjoy the game. My plan was to integrate it with the overall world-building to explore and find clues while flying around. The facility is supposed to be one of many indoor exploration elements. When designing the interiors, I wanted to reward the player for taking their time to explore. To present a problem early on, giving away hints of the goal and then presenting what they need to solve it.

The Mass Effect trilogy inspired me a lot, in particular the part when you get to scan planets for materials. The interior on this planet sets inside an abandoned space station, where the first mission is to activate the energy base. Light is a rare resource, and more rare items are placed in the darkest corners to encourage replayability. A player that upgrades their gear to cast light means they get more material. The interior balances open and narrow spaces to give the player a well paced experience navigating the station and rewarding curiosity.

I think it’s important when the design indicates what you have to do differently to progress. For example, explaining why some actions are not possible, like “you can’t land your ship here because it’s covered in vegetation!”. Punishments have to provide the player with some insight. As a level designer, I believe that I can create the game world to be perceived as whatever I want the player to experience. Their experience is what’s most important rather than what’s logical. It doesn’t necessarily need to be realistic but to seem reasonable within the framework of the world. For example, to give them the feeling of almost succeeding even though they were pretty far off. Fake it until they make it! I feel like that’s one way I want to encourage my players.

Here are some sketches to illustrate some of the interactive game mechanics and interior.

Asset creation in Blender

I’ve experimented with Blender for a couple of years now, and when I got accepted at TGA I decided to try and create all the 3D modeling myself. I like how it gives me total control over what I can create, and I don’t have to depend on somebody else’s design.

One of my first prototypes of the vehicle before it became a helicopter, weapons and some of the modular interior design.


The flight controller plays a big part in what makes the game fun. It was vital for me that the basics of flying around and landing were enjoyable for a novice player. The spaceship is controlled like a helicopter so whenever the ship is leaning towards a direction, that’s the direction it will go. The player can land and exit the ship to explore gated areas of the level. My goal for the movement controller was to make the player feel engaged and I therefore spent a lot of time tweaking variables. I will continue to develop this project and my next step is to add enemy encounters and environmental hazards.

HandleLift function

One of the first functions I wrote was to make the spaceship lift, it felt like a good starting point. Testing different heights gave me the information I needed to design the terrain.

 protected virtual void HandleLift(Rigidbody rb, KeyboardInput input)
            // handles the vertical movement of the ship, make sure that the spaceship can have "room to move" 
            RaycastHit groundHit;
            Ray groundRay = new Ray(transform.position, Vector3.down);
            if (Physics.SphereCast(groundRay, .1f, out groundHit, Mathf.Infinity, enviormentMask))
                currentWantedPlayerHeight = groundHit.distance;
                currentUsablePlayerHeight = Mathf.Lerp(currentUsablePlayerHeight ,currentWantedPlayerHeight , Time.deltaTime);
                RaycastHit playerExitShipHit;
                Ray playerExitShipRay = new Ray(transform.position, Vector3.down);
                if (Physics.SphereCast(playerExitShipRay, .1f, out playerExitShipHit, Mathf.Infinity))
                    if (playerExitShipHit.distance < minGroundDistanceForDeloadPlayer)
                    playerPoint = playerExitShipHit.point  + transform.up * 6f;
                        if (Input.GetKey(KeyCode.C))
                            FPSCam.gameObject.transform.position = playerPoint;
                        if (!canSpawn)
                        canSpawn = true;                                       
                }else canSpawn =false;

                RaycastHit forwardHit;
                Ray forwardRay = new Ray(groundHit.point, -flatFwd);

                if (Physics.Raycast(forwardRay, out forwardHit, maxDiffDistance, enviormentMask))
                    distanceToEnv = forwardHit.distance;
                    Ray upRay = new Ray(forwardHit.point, Vector3.up);
                    RaycastHit upHit;

                    if (Physics.SphereCast(upRay, .5f, out upHit, Mathf.Infinity, topMask))
                        currentEnvAimHeight = upHit.distance + minConsistantHeight;
                        Ray topRay = new Ray(upHit.point, Vector3.up);
                        RaycastHit topHit;

                        if (Physics.SphereCast(topRay, rayRadius, out topHit, Mathf.Infinity, topMask))
                            top = topHit.distance;
                            currentEnvAimHeight += top / 2;
                        heightModifier = currentEnvAimHeight - currentUsablePlayerHeight + minConsistantHeight;
                        dir = heightModifier / Mathf.Abs(heightModifier);
                        powerMuliplier = heightModifier / maxDiffHeight;
                        heightModifier = maxDiffHeight - currentUsablePlayerHeight + minConsistantHeight;
                        dir = heightModifier / Mathf.Abs(heightModifier);
                        powerMuliplier = heightModifier / maxDiffHeight;
                    heightModifier = maxDiffHeight - currentUsablePlayerHeight + minConsistantHeight;
                    dir = heightModifier / Mathf.Abs(heightModifier);

                    powerMuliplier = heightModifier / maxDiffHeight;
            power = Mathf.Pow(powerMuliplier, pow) * maxLiftForce * dir;
            power = Mathf.Clamp(power, 0, maxLiftForce);

            Vector3 liftForce = (transform.up * (Physics.gravity.magnitude + power) * rb.mass);
            rb.AddForce(liftForce * input.StickeyCollectiveInput, ForceMode.Force);

CalculateAngles function

To find what direction I needed to push the spacecraft in, I could not use the world location since it did not consider what was forward and backward. The local angle was pointing straight down. Therefore, the dot product gave me a flat angle.

 private void CalculateAngles()
            // Calculate the flat angle in world space
            flatRight = transform.right;
            flatRight.y = 0f;
            flatRight = flatRight.normalized;
            Debug.DrawRay(transform.position, flatRight, Color.red);
            flatFwd = transform.forward;
            flatFwd.y = 0f;
            flatFwd = flatFwd.normalized;
            Debug.DrawRay(transform.position, flatFwd, Color.blue);
            forwardDot = Vector3.Dot(transform.up, flatFwd);
            rightDot = Vector3.Dot(transform.up, flatRight);

HandleCyclic function

This function handles the amount of force implemented to the spaceship. If anything is blocking the path, the player will get less power added to the ship.

  protected virtual void HandleCyclic(Rigidbody rb, KeyboardInput input)
            // Handle the amount of stability the player receives, decrease stability when any input is received to give more control to the steering of the ship. 
            if (Input.anyKey)
                stability = Mathf.Lerp(maxStability, 0.2f, 5 * Time.deltaTime);             
                stability = Mathf.Lerp(maxStability, maxStability, 5 * Time.deltaTime);

            //Handle the tilting of the spaceship in forward / backward directions    
            float cyclicXForce = (MathF.Pow(input.CyclicInput.y, pow) * (cyclicForce * cyclicForceMultiplier));
            rb.AddRelativeTorque(Vector3.right * (cyclicXForce * input.CyclicInput.y), ForceMode.Acceleration);
            //Handle the tilting of the spaceship in left / right directions   
            float cyclicZForce = MathF.Pow(input.CyclicInput.x, pow) * (cyclicForce * cyclicForceMultiplier);
            rb.AddRelativeTorque(Vector3.forward * (cyclicZForce * input.CyclicInput.x), ForceMode.Acceleration);

            Vector3 forwardVec = (flatFwd * forwardDot);
            Vector3 rightVec = (flatRight * rightDot);
            // calculates the final angle to apply force in. 
            cyclicDir = Vector3.ClampMagnitude(forwardVec + rightVec, 1f) * (forwardPower * cyclicForce);
            RaycastHit hit;
            Ray ray = new Ray(transform.position, cyclicDir);
            if (Physics.SphereCast(ray, .2f, out hit, maxDiffDistance, enviormentMask))
                // Calculate the distance to the closest object in the world in the direction witch the ship is going to move toward 
                distanceModifier = maxDiffDistance - hit.distance;
                distanceForceMultiplier = distanceModifier / maxDiffDistance;
                if(hit.distance > maxDiffDistance *.1f)
                    forwardPower = (1 - distanceForceMultiplier) * Mathf.Pow(distanceForceMultiplier, pow) * maxForwardPowerMuliplier;
                    rb.AddForce(cyclicDir, ForceMode.Force);
                    // handle the "breaking" of the ship by reversing the power when the ship is less than 10m away from the object
                    forwardPower =  distanceForceMultiplier * Mathf.Pow(distanceForceMultiplier, pow) * maxForwardPowerMuliplier;
                    rb.AddForce(-cyclicDir, ForceMode.Force);

                // apply force in the direction wich the ship is tilting toward
                forwardPower = Mathf.Clamp(forwardPower, 0, maxForwardPowerMuliplier);

            else {
                // apply force in the direction wich the ship is tilting toward
                forwardPower = maxForwardPowerMuliplier;
                rb.AddForce(cyclicDir, ForceMode.Force);

AutoLevel function

This function handles the amount of force implemented to the spaceship. If anything is blocking the path, the player will get less power added to the ship. The amount of auto-level force gets determined by the input given by the player. Since auto-leveling, the spaceship gives less control of the pitch of the spacecraft. It was the best solution to provide the player with control whenever they want to move somewhere, but they can take their hands off the keyboard if they hit something and start to wobble.

 private void AutoLevel(Rigidbody rb)
            // Calculate in what direction the spaceship need to apply force to be able to stay steady in the air
            Vector3 predictedUp = Quaternion.AngleAxis(
          rb.angularVelocity.magnitude * Mathf.Rad2Deg * stability / maxStability,
          ) * transform.up;
            Vector3 torqueVector = Vector3.Cross(predictedUp, Vector3.up);
            rb.AddTorque(torqueVector * maxStability * stability);
            Quaternion corrAngle = Quaternion.Euler(0f, 0f, 0f);

            float rightForce = -forwardDot * autolevelForce;
            float forwardForce = rightDot * autolevelForce;

            rb.AddRelativeTorque(Vector3.right * rightForce, ForceMode.Acceleration);
            rb.AddRelativeTorque(Vector3.forward * forwardForce, ForceMode.Acceleration);

HandlePlasmaCanon function

This function finds points in the world where the mouse is hovering, and the cannon moves towards that point slowly. The projectiles move forward and check if they collide with anything. The script then spawns collision effects to communicate a hit to the player.

void HandlePlasmaCanon()
            Quaternion OriginalRot = gunPos.localRotation;
            Quaternion NewRot = gunPos.localRotation;
            gunPos.localRotation = OriginalRot;
            gunPos.localRotation = Quaternion.Lerp(gunPos.localRotation, NewRot, rotSpeed * Time.deltaTime);
            foreach (Projectile projectile in projectileList)
                    RaycastHit projectileHit;
                    Ray projectileRay = new Ray(projectile.transform.position, projectile.transform.forward);
                    if (Physics.SphereCast(projectileRay, .2f, out projectileHit, Mathf.Infinity))
                        Debug.DrawLine(projectile.transform.position, projectileHit.point, Color.cyan, 2000000f);
                        if (projectileHit.distance < 1f && projectileHit.distance > .5f)
            RaycastHit hit;
            Ray ray = new Ray(firePos.position, gunPos.forward);
            if (Physics.SphereCast(ray, 0.2f, out hit, shootRange, gunMask))
                aim.position = hit.point;
                aim.rotation = Quaternion.FromToRotation(aim.up, hit.normal) * aim.rotation;
                hitEffectPos = aim;


            if (Input.GetButton("Fire1") && canShoot)

  IEnumerator InstantiateHitPlane(Vector3 pos)
            GameObject planeInstance = Instantiate(impactPlane,pos, hitEffectPos.rotation);
            yield return new WaitForSeconds(.1f);
        IEnumerator InstantiateHitEffect(Vector3 pos)
            VisualEffect hitEffectInsante = Instantiate(impactVFX, pos, hitEffectPos.rotation);
            hitEffectInsante.transform.localScale = new Vector3(5, 5, 5);
            yield return new WaitForSeconds(hitEffectInsante.playRate); 
            foreach(VisualEffect vfx in vFXList)

        void Shoot()

            RaycastHit hit;
            Ray ray = new Ray(firePos.position, gunPos.forward);
            if (Physics.SphereCast(ray, 0.2f, out hit, shootRange, gunMask))
                Projectile projectileInstance = Instantiate(projectile, firePos.position, gunPos.rotation);
                VisualEffect muzzleFlash = Instantiate(muzzleFlashVFX, firePos);
                muzzleFlash.transform.localPosition = new Vector3(0, 0, 0);
                muzzleFlash.transform.localScale = new Vector3(5, 5, 5);



        IEnumerator Timer()
            canShoot = false;
            yield return new WaitForSeconds(1f); 
            canShoot = true;

HandlePedal function

This function rotates the spacecraft horizontally.

protected virtual void HandlePedal(Rigidbody rb, KeyboardInput input)
            rb.AddTorque(Vector3.up * input.PedalInput * tailForce, ForceMode.Acceleration);

FPSController function

This function controls the camera movement when the player exits the spacecraft to explore.

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

namespace Spaceship
    public enum playerMoveStatus { notMoving, crouching, walking, running, notGrounded, landing }
    public enum CurvedControlledBobCallBackType {horizontal, vertical}
    public delegate void CurvedControlledBobCallback();

    public class CurvedControlledBobEvent
        public float time = 0f;
        public CurvedControlledBobCallback Function = null;
        public CurvedControlledBobCallBackType Type =  CurvedControlledBobCallBackType.vertical;
    public class CurvedControlledBob
        [SerializeField] AnimationCurve bobCurve = new AnimationCurve(new Keyframe(0f,0f),new Keyframe(0.5f, 1f)
        ,new Keyframe(1f, 0f), new Keyframe(1.5f, -1f), new Keyframe(2f, 0f));
        [SerializeField] float horizontalMultiplier = 0.01f;
        [SerializeField] float verticalMultiplier = 0.02f;
        [SerializeField] float verticaltoHorizontalSpeedRatio = 2.0f;
        [SerializeField]  float baseInterval = 1f;
        private float prevXPlayHead;
        private float prevYPlayHead;
        private float xPlayHead;
        private float yPlayHead;
        private float cureveEndTime;

        private List<CurvedControlledBobEvent> events = new List<CurvedControlledBobEvent>();

        public void Initialize ()
             cureveEndTime = bobCurve[bobCurve.length - 1].time;
             xPlayHead = 0f;
             yPlayHead = 0f;
             prevXPlayHead = 0f;
             prevYPlayHead = 0f;
        public void RegisterEventCallback(float time, CurvedControlledBobCallback function, CurvedControlledBobCallBackType type)
            CurvedControlledBobEvent  ccbeEnvet = new CurvedControlledBobEvent();
            ccbeEnvet.time = time;
            ccbeEnvet.Function = function;
            ccbeEnvet.Type = type;
                delegate(CurvedControlledBobEvent t1, CurvedControlledBobEvent t2)
                    return (t1.time.CompareTo (t2.time));

        public  Vector3 GetVectorOffset(float speed)
            xPlayHead += (speed * Time.deltaTime)/ baseInterval;
            yPlayHead += ((speed * Time.deltaTime) / baseInterval)* verticaltoHorizontalSpeedRatio;

            if(xPlayHead > cureveEndTime)   
            xPlayHead -= cureveEndTime;
            if (yPlayHead > cureveEndTime)
                yPlayHead -= cureveEndTime;

                for (int i = 0; i < events.Count; i++)
                    CurvedControlledBobEvent ev = events[i];
                        if(ev.Type == CurvedControlledBobCallBackType.vertical)
                            if(prevYPlayHead < ev.time && yPlayHead >= ev.time || prevYPlayHead > yPlayHead && (ev.time > prevYPlayHead || ev.time <= yPlayHead))
                        if (ev.Type == CurvedControlledBobCallBackType.vertical)                        
                            if (prevXPlayHead < ev.time && xPlayHead >= ev.time || prevXPlayHead > xPlayHead && (ev.time > prevXPlayHead || ev.time <= xPlayHead))                            
                float xPos = bobCurve.Evaluate(xPlayHead)*horizontalMultiplier;
                float yPos = bobCurve.Evaluate(yPlayHead)*verticalMultiplier;

                prevXPlayHead = xPlayHead;
                prevYPlayHead  = yPlayHead;

                return new Vector3(xPos,yPos,0f);

    public class FPSController : MonoBehaviour
        public List<AudioSource> audioSource = new List<AudioSource>();
        private int audioToUse = 0;
        public Vector3 spawnPoint { get; set; }
        #region Gameplay
        [SerializeField] private float crouchSpeed = 2.5f;
        [SerializeField] private float walkSpeed = 1f;
        [SerializeField] private float runSpeed = 4.5f;
        [SerializeField] private float jumpSpeed = 7.5f;
        [SerializeField] private float stickToGroundForce = 5f;
        [SerializeField] private float gravityMultiplier = 2.5f;
        [SerializeField] private float runStepLengthen = 0.75f;
        [SerializeField] private float walkStepLengthen = 0.75f;
        [SerializeField] private float staminaDepletion = 5f;
        [SerializeField] private float staminaRecovery = 10f;
        [SerializeField] private MouseLook mouseLook;
        [SerializeField] private CurvedControlledBob headBob = new CurvedControlledBob();
        // [SerializeField] SpaceShipCharacteristics charactaristics;
        const float interactDistance = 20f;
        public Camera fpsCamera = null;
        private bool isJumpButtonPressed = false;
        private Vector2 input = Vector2.zero;
        private Vector3 moveDiection = Vector2.zero;
        private bool pervGrounded;
        private bool isWalking = false;
        private bool isJumping = false;
        private bool isCrouching = false;
        private float fallingTimer = 0f;
        private float controllerHeight = 0f;
        private float stamina = 100f;
        private Vector3 localSpaceCameraPos = Vector3.zero;
        private CharacterController characterController = null;
        private playerMoveStatus movementStatus = playerMoveStatus.notMoving;
        public LayerMask groundMask;
        public playerMoveStatus movemeStatus { get { return movementStatus; }}
        public float WalkSpeed { get { return walkSpeed; }}
        public float RunSpeed  { get { return runSpeed;  }}
        [SerializeField] LayerMask interactebleMask;
        float vertical;
        float horizontal;

       void Update ()
            // if(Input.GetButtonDown("Interact"))
            //     Interact();
            if (characterController.isGrounded) fallingTimer = 0f;
            fallingTimer += Time.deltaTime;

                mouseLook.LookRotation(transform, fpsCamera.transform);

            if (!isJumpButtonPressed && !isCrouching)
                isJumpButtonPressed = Input.GetButtonDown("Jump");
                isCrouching = !isCrouching;
                characterController.height = isCrouching == true ? controllerHeight/2f : controllerHeight;

            if (!pervGrounded && characterController.isGrounded)
                if (fallingTimer > 0.5f)
                moveDiection.y = 0f;
                isJumping = false;
                movementStatus = playerMoveStatus.landing;
            else if (characterController.velocity.sqrMagnitude < 0.01f)            
                movementStatus = playerMoveStatus.notMoving;
            else if (isCrouching)
                movementStatus = playerMoveStatus.crouching;
            else if (isWalking)
                movementStatus = playerMoveStatus.walking;
                movementStatus = playerMoveStatus.running;
            pervGrounded = characterController.isGrounded;

            if(movementStatus == playerMoveStatus.running)
            stamina = Mathf.Max(stamina - staminaDepletion * Time.deltaTime, 0f);
            stamina = Mathf.Min(stamina + staminaRecovery * Time.deltaTime, 100f);

       private void FixedUpdate()
            horizontal = Input.GetAxis("Horizontal");
            vertical = Input.GetAxis("Vertical");
            bool wasWalking = isWalking;
            isWalking = !Input.GetKey(KeyCode.LeftShift);
            float speed = isCrouching ? crouchSpeed :  isWalking ? walkSpeed : Mathf.Lerp(walkSpeed,runSpeed, stamina/100f);
            input = new Vector2(horizontal, vertical);
            if (input.sqrMagnitude > 1) input.Normalize();

            Vector3 desierdMove = transform.forward * input.y + transform.right * input.x;
            RaycastHit hit;
            Ray ray = new Ray(transform.position, Vector3.down);
            if (Physics.SphereCast(ray, characterController.radius, out hit, characterController.height / 2f, groundMask))
                desierdMove = Vector3.ProjectOnPlane(desierdMove, hit.normal).normalized;

            moveDiection.x = desierdMove.x * speed;
            moveDiection.z = desierdMove.z * speed;
            if (characterController.isGrounded)
                moveDiection.y = -stickToGroundForce;
                if (isJumpButtonPressed)
                    moveDiection.y = jumpSpeed;
                    isJumpButtonPressed = false;
                    isJumping = true;
                moveDiection += Physics.gravity * gravityMultiplier * Time.fixedDeltaTime;

            characterController.Move(moveDiection * Time.fixedDeltaTime);

            Vector3 speedXZ = new Vector3(characterController.velocity.x, 0f, characterController.velocity.z);

            if(speedXZ.magnitude > 0.01f)
                fpsCamera.transform.localPosition = new Vector3(0f,0f,0f) + headBob.GetVectorOffset(speedXZ.magnitude * (isWalking || isCrouching? walkStepLengthen : runStepLengthen));
            else fpsCamera.gameObject.transform.localPosition = new Vector3(0f, 0f, 0f);


        public float Stamina { get{return stamina; }}

            private void Start() 
            characterController = GetComponent<CharacterController>();
            controllerHeight = characterController.height;       
            fpsCamera = GetComponentInChildren<Camera>();
            mouseLook.Init(transform, fpsCamera.transform);
            headBob.RegisterEventCallback(1.5f, PlayFootstepSound, CurvedControlledBobCallBackType.vertical);          
        void PlayFootstepSound()
            if(isCrouching) return;
            audioToUse = (audioToUse == 0) ? 1 : 0;

        // void Interact ()
        // {
        //     RaycastHit hit;
        //     Ray ray = new Ray (transform.position, transform.forward);
        //     if(Physics.SphereCast(ray, .1f, out hit, interactDistance))
        //     {
        //         if(hit.transform.gameObject.GetComponent<IInteracteble>() != null)
        //         {
        //             Debug.Log(hit.transform.gameObject);
        //             hit.transform.gameObject.GetComponent<IInteracteble>().OnInteract();
        //         }
        //     }
        // } 
        private void OnEnable() 
            GameObject spaceship = GameObject.FindGameObjectWithTag("spaceship");
           transform.position =  spaceship.transform.position;

            characterController = GetComponent<CharacterController>();
            controllerHeight = characterController.height;
            fpsCamera = GetComponentInChildren<Camera>();
            mouseLook.Init(transform, fpsCamera.transform);
            headBob.RegisterEventCallback(1.5f, PlayFootstepSound, CurvedControlledBobCallBackType.vertical);