Doughy Dilemma

I embarked on a personal project where I developed a game titled "Doughy Dilemma", drawing inspiration from "A Short Hike". Here's a glimpse into the planning, production, and iteration stages of this passion project.

Team size

Solo developer

Role

Technical Designer

Time

5 Weeks

Software

Unity, Blender, Github, Adobe Illustrator, Trello, Figma

Planning & Prototyping

The project kicked off with a comprehensive week of planning starting with the Game Design Document and Level Design Document where I created flowcharts to visualize the player mechanics. I utilized Trello to help establish a clear roadmap for the project.


This transition involved documenting each player state and crafting a flowchart illustrating the conditions necessary for state transitions and their respective outcomes.

Boss Flowchart

Falling Stones Flowchart

Production

The decision to implement a state machine proved pivotal for maintaining a modular and organized character state management system. This choice enhanced scalability, eased maintenance, and debugging compared to a large script.

Walk States

Jumping / Falling States

Climbing State

Biking State

Jetpack State

State Runner

To implement the state machine, I had to familiarize myself with abstract classes and how to inherit them in the character controller.

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

namespace StateMachine
{
    // StateRunner is a MonoBehaviour that controls the lifecycle and transitions of various states.
    // This is a generic class that can handle any state for a given MonoBehaviour.
    public abstract class StateRunner<T> : MonoBehaviour where T : MonoBehaviour
    {
        // List of available states for this runner.
        [SerializeField]
        private List<State<T>> _states;

        // Dictionary mapping each state's type to its instance for fast access.
        private readonly Dictionary<Type, State<T>> _stateByType = new();

        // The currently active state this runner is in.
        private State<T> _activeState;

        // On Awake, we initialize our state dictionary and set the first state as the active state.
        protected virtual void Awake()
        {
            // Populate the dictionary with each state's type as the key and the state itself as the value.
            _states.ForEach(s => _stateByType.Add(s.GetType(), s));

            // Set the initial state to the first state in our list.
            SetState(_states[0].GetType());
        }

        // Method to transition to a new state using the state's type.
        public void SetState(Type newStateType)
        {
            // If there's an active state, call its Exit method to handle any state-exit logic.
            if (_activeState != null)
            {
                _activeState.Exit();
            }

            // Set the new state as the active state and initialize it.
            _activeState = _stateByType[newStateType];
            _activeState.Init(GetComponent<T>());
        }

        // Unity's Update method where we handle input and any update logic for the active state.
        private void Update()
        {
            _activeState.CaptureInput();
            _activeState.Update();
        }

        // Unity's FixedUpdate method where we potentially change states and handle any fixed update logic.
        private void FixedUpdate()
        {
            _activeState.ChangeState();
            _activeState.FixedUpdate();
        }
    }
}

State Runner that CharacterCtrl inherits from

using UnityEngine;
using UnityEngine.InputSystem;

namespace StateMachine
{

    public class CharacterCtrl : StateRunner<CharacterCtrl>
    {

        [Header("Reference Other Scripts")]
        public PlayerMovementBigActionMap PMBA;
        public GameManager GM;
        public InputHandler IH { get; private set; }
        [SerializeField] AmIGrounded _AIG;
        public IsFacingWall _IFW;
        public Jumping _jumpingSC;

        [Header("GameObjects")]
        [SerializeField] private GameObject playerprefab;
        [SerializeField] private GameObject ClimbingPrefab;

        
        [Header("Components")]
        [SerializeField] public Rigidbody playerRb;
      
        [SerializeField] Animator playerAnim;
        [SerializeField] Animator _JetPackAnim;
        private PlayerInput playerinput;
        [SerializeField] private CapsuleCollider playercollider;

        [Header("Animation")]
        int moveAnimationID;
        int moveWithBagId;


        public bool _isGrounded;
        

        [Header("Floats")]
        [SerializeField] private float onGroundMoveForceSlow;
        [SerializeField] private float onGroundMoveForceNormal;
        [SerializeField] private float OnGroundRun;
        [SerializeField] private float _rotationSpeed;


        [Header("Jumping")]
        public float jumpForce = 10f;
        public float maxJumpTime = 1f;
        public float fallMultiplier = 2.5f;
        public float jumpTimer = 0.2f;
        public int maxJumps = 2;
        public int jumpCount = 0;
        public bool isJumpingPressed;
        public bool jump;
        [Header("Bike")]
        
       

        [Header("Climbing")]
        [SerializeField] public bool readyToClimb;
        [SerializeField] private float climbspeed;
        [SerializeField] private float wallDistanceOffset;
        [SerializeField] private float sideRaycastOffset;

        [Header("Transforms")]
        [SerializeField] private Transform orientation;
        [SerializeField] private Transform _thisObject;
        [SerializeField] private Transform _cameraPlayer;
        [SerializeField] private Transform groundCheck;

       

        [Header("Vector3")]
        //private Vector3 movement;
        private Vector3 movementBike;
        private Vector3 upDirection = Vector3.up;

        [Header("Layers")]
        [SerializeField] private LayerMask groundLayer;
        [SerializeField] private LayerMask ClimbLayer;

        [Header("Slope Handling")]
        [SerializeField] private float maxSlopeAngle;
        private RaycastHit slopeHit;


        //Bike
        [SerializeField] private TurnBikeWheels _tB;
        [SerializeField] private Transform _bikeOrientation;
        [SerializeField] private GameObject _bike;
        [SerializeField] private GameObject _fork;
        [SerializeField] private float _forkRotationSpeed;
        [SerializeField] private float _bikespeed;
        [SerializeField] private float _rotationSpeedBike;
        [SerializeField] private float _minRotateValueSpeedBike;
        [SerializeField] private float _maxRotateValueSpeedBike;
        [SerializeField] private float _minWheelTurn;
        [SerializeField] private float _maxWheelTurn;
        [SerializeField] private float _minValueSpeedBike;
        [SerializeField] private float _maxValueSpeedBike;

        //JetPack
        [SerializeField] private GameObject _JetPack;
        // Hand And Feet Transforms
        [Header("Transforms")]

        [Header("Trackers Bike")]
        [SerializeField] private Transform _leftHandBike, _rightHandBike, _leftFootBike, _rightFootBike;

        [Header("trackers Hands and Feet")]
        [SerializeField] private Transform _leftHand, _rightHand, _leftFoot, _rightFoot;

        [Header("Trackers Climbing")]
        [SerializeField] private Transform _lefthandClimb, _rightHandClimb, _leftFootClimb, _rightFootClimb;

        // Getters And Setters
        public Animator PlayerAnimator { get { return playerAnim; } set { playerAnim = value; } }
        public Animator JetPackAnimator { get { return _JetPackAnim; } set { _JetPackAnim = value; } }
        public CapsuleCollider PlayerCollider { get { return playercollider; } set { playercollider = value; } }
        public Rigidbody PlayerRB { get { return playerRb; } set { playerRb = value; } }
        public TurnBikeWheels TB { get { return _tB; } set { _tB = value; } }

        //public GameManager GM { get { return _GM; } set { _GM = value; } }
        public AmIGrounded AIG { get { return _AIG; } }

        //Getters And Setters Bools
        public bool ISGrounded { get { return _isGrounded; } }

        // Getters And Setters GameObjects
        public GameObject Bike { get { return _bike; } set { _bike = value; } }
        public GameObject Fork { get { return _fork; } set { _fork = value; } }
        public GameObject JetPack { get { return _JetPack; } set { _JetPack = value; } }

        // Getters And Setters Floats
        public float BikeSpeed { get { return _bikespeed; } set { _bikespeed = value; } }
        public float ForkRotationSpeed { get { return _forkRotationSpeed; } set { _forkRotationSpeed = value; } }
        public float RotationSpeedBike { get { return _rotationSpeedBike; } set { _rotationSpeedBike = value; } }
        public float RotationSpeed { get { return _rotationSpeed; } }
        public float MinRotateValueSpeedBike { get { return _minRotateValueSpeedBike; } }
        public float MaxRotateValueSpeedBike { get { return _maxRotateValueSpeedBike; } }
        public float MinWheelTurn { get { return _minWheelTurn; } }
        public float MaxWheelTurn { get { return _maxWheelTurn; } }
        public float MinValueSpeedBike { get { return _minValueSpeedBike; } }
        public float MaxValueSpeedBike { get { return _maxValueSpeedBike; } }
        public float OnGroundMoveForceNormal { get { return onGroundMoveForceNormal; } }
        public float OnGroundMoveForceSlow { get { return onGroundMoveForceSlow; } }
        public float OnGroundRunning { get { return OnGroundRun; } }
        public float JumpTimer { get { return jumpTimer; } set { jumpTimer = value; } }
        public float JumpForce { get { return jumpForce; } set { jumpForce = value; } }
        public float MaxJumpTime { get { return maxJumpTime; } set { maxJumpTime = value; } }
        public float FallMultiplier { get { return fallMultiplier; } set { fallMultiplier = value; } }

        // Getters And Setters Animation
        public int MoveAnimationID { get { return moveAnimationID; } set { moveAnimationID = value; } }
        public int MoveWithBagID { get { return moveWithBagId; } set { moveWithBagId = value; } }
        public int JumpCount { get { return jumpCount; } set { jumpCount = value; } }
        public int MaxJumps { get { return maxJumps; } set { maxJumps = value; } }
        // Getters And Setters Transforms General
        public Transform CameraPlayer { get { return _cameraPlayer; } }
        public Transform ThisObject { get { return _thisObject; } set { _thisObject = value; } }
        public Transform BikeOrientation { get { return _bikeOrientation; } }
        public Transform Orientation { get { return orientation; } }


        //Getters and Setters Hand And Feet Transforms Bikes;
        public Transform LeftHandBike { get { return _leftHandBike; } }
        public Transform LeftFootBike { get { return _leftFootBike; } }
        public Transform RightHandBike { get { return _rightHandBike; } }
        public Transform RightFootBike { get { return _rightFootBike; } }

        //Getters And Setters Hand And Feet Transforms
        public Transform _LeftHand { get { return _leftHand; } set { _leftHand = value; } }
        public Transform _LeftFoot { get { return _leftFoot; } set { _leftFoot = value; } }
        public Transform _RightHand { get { return _rightHand; } set { _rightHand = value; } }
        public Transform _RightFoot { get { return _rightFoot; } set { _rightHand = value; } }

        protected override void Awake()
        {
            base.Awake();
            IH = GetComponent<InputHandler>();
            moveAnimationID = Animator.StringToHash("Move");
            moveWithBagId = Animator.StringToHash("moveWithBag");
            _IFW = GetComponent<IsFacingWall>();
            _jumpingSC = GetComponent<Jumping>();
            GM = FindObjectOfType<GameManager>();
           
        }

        
    }
   
}

Character Controller

Scriptable Objects

I used scriptable objects for the states, organizing them in a list. This approach allowed for real-time adjustments during playtesting, making the development process smoother.

using UnityEngine;
using UnityEngine.InputSystem;

namespace StateMachine
{
    // The abstract class State is a ScriptableObject that can represent a state for a MonoBehaviour.
    // It's generic, which means it can be used for any MonoBehaviour (indicated by the <T>).
    public abstract class State<T> : ScriptableObject where T : MonoBehaviour
    {
        // _runner is a reference to the MonoBehaviour that is running this state.
        protected T _runner;

        // Initialize the state with a reference to its parent MonoBehaviour.
        public virtual void Init(T parent)
        {
            _runner = parent;
        }
       
        // Abstract method to capture any required input.
        // Subclasses must provide an implementation for this.
        public abstract void CaptureInput();

        // Abstract method for update logic.
        // Subclasses must provide an implementation for this.
        public abstract void Update();

        // Abstract method for fixed update logic.
        // Subclasses must provide an implementation for this.
        public abstract void FixedUpdate();

        // Abstract method to change to another state.
        // Subclasses must provide an implementation for this.
        public abstract void ChangeState();

        // Abstract method for logic when exiting the state.
        // Subclasses must provide an implementation for this.
        public abstract void Exit();
    }
}

State class all states inherits from

using UnityEngine;
using UnityEngine.InputSystem;

namespace StateMachine
{

    [CreateAssetMenu(menuName = "States/Player/Vehicle/Bike")]
    public class BikeState : State<CharacterCtrl>
    {

        // References to various components and managers
        CharacterCtrl _parent;
        IsFacingWall _IFW;
        AmIGrounded _AIG;
        GameManager _GM;

        //collider
        private CapsuleCollider _playercollider;

        
        // Animator
        private Animator _playerAnim;

        //Generic Transforms
        private Transform _bikeOrientation;
        private Transform _cameraPlayer;
        private Transform _thisObject;

        //Rigidbody off the player
        private Rigidbody _playerRB;

        //Wheel script
        private TurnBikeWheels _TB;

        // GameObjects
        public GameObject _bike;
        public GameObject _fork;

        // Transforms on bike for hands and feet
        private Transform _leftFootBike;
        private Transform _rightFootBike;
        private Transform _leftHandBike;
        private Transform _rightHandBike;

        //Transforms on player Hands And Feet

        private Transform _leftHand;
        private Transform _leftFoot;
        private Transform _rightHand;
        private Transform _rightFoot;

        //Vectors
        private Vector2 _inputVectorOnBike;
        private Vector2 _inputVectorOnBikeSpeed;
        private Vector2 _bikeMove;
        private Vector3 _movementBike;
        private Vector2 previousJoystickValue;

        // Floats
        private float _bikeSpeed;
        private float _forkRotationSpeed;
        private float _rotationSpeedBike;
        private float _rotationSpeed;
        private float _minRotateValueSpeedBike;
        private float _maxRotateValueSpeedBike;
        private float _minWheelTurn;
        private float _maxWheelTurn;
        private float _minValueSpeedBike;
        private float _maxValueSpeedBike;
        [SerializeField] private float RunAccelRate;
        [SerializeField] private float RunDecelRate;
        [SerializeField] private float _timeChangeV;
        [SerializeField] private float _timeLeft;

        //Bools
        private bool _changeVehicle;


        public override void Init(CharacterCtrl parent)
        {

            // Initialization logic when the state is first created
            base.Init(parent);

            _parent = parent;
            _playercollider = parent.PlayerCollider;
            _playerAnim = parent.PlayerAnimator;
            _playerRB = parent.PlayerRB;
            _TB = parent.TB;

            _bikeOrientation = parent.BikeOrientation;
            _cameraPlayer = parent.CameraPlayer;
            _thisObject = parent.ThisObject;

            _bike = parent.Bike;
            _fork = parent.Fork;

            _leftFootBike = parent.LeftFootBike;
            _rightFootBike = parent.RightFootBike;
            _leftHandBike = parent.LeftHandBike;
            _rightHandBike = parent.RightHandBike;

            _leftFoot = parent._LeftFoot;
            _rightFoot = parent._RightFoot;
            _leftHand = parent._LeftHand;
            _rightHand = parent._RightHand;
           
            _timeLeft = _timeChangeV;

            _bikeSpeed = parent.BikeSpeed;
            _forkRotationSpeed = parent.ForkRotationSpeed;
            _rotationSpeedBike = parent.RotationSpeedBike;
            _rotationSpeed = parent.RotationSpeed;
            _minRotateValueSpeedBike = parent.MinRotateValueSpeedBike;
            _maxRotateValueSpeedBike = parent.MaxRotateValueSpeedBike;
            _minWheelTurn = parent.MinWheelTurn;
            _maxWheelTurn = parent.MaxWheelTurn;
            _minValueSpeedBike = parent.MinValueSpeedBike;
            _maxValueSpeedBike = parent.MaxValueSpeedBike;

           
        }
        
        public override void CaptureInput()
        {
            
        }
        public override void Update()
        {
          
        }
        public override void FixedUpdate()
        {
            // Physics-based update logic

            _changeVehicle = _parent.IH.SwitchVehicle1;

            // setting the Inputvector of the movement
            _inputVectorOnBike = _parent.IH.InputVectorOnBike;

            _inputVectorOnBikeSpeed = _parent.IH.InputVectorOnBikeSpeed;

            _timeLeft -= Time.deltaTime;

            _AIG = _parent.AIG;

            _GM = _parent.GM;
           
            //Setting the animation for the bike state
            _playerAnim.SetBool("OnBike", true);

            // Assigning different body parts off the player to the bike
            _leftFoot.transform.position = _leftFootBike.transform.position;      
            _rightFoot.transform.position = _rightFootBike.transform.position;    
            _leftHand.transform.position = _leftHandBike.transform.position;      
            _rightHand.transform.position = _rightHandBike.transform.position;    

            // turning on the Bike GameObject
            _bike.SetActive(true);

            // Turning off the Collider on the Player
            _playercollider.enabled = false;

            //Bike Moves
            if (_inputVectorOnBike.magnitude >= .1f)
            {
                _IFW = _parent._IFW;


                float forward = _inputVectorOnBike.y * _bikeSpeed;
                float right = _inputVectorOnBike.x * _bikeSpeed;
                Vector3 targetSpeed = (!_IFW._isFacingWall() ? _bikeOrientation.forward : Vector3.zero) * forward + _bikeOrientation.right * right;

                Vector3 velocity = _playerRB.velocity;
                velocity.y = 0;

                Vector3 speedDiff = targetSpeed - velocity;

                float accelRate = (Mathf.Abs(targetSpeed.magnitude) >= 0.5f) ? RunAccelRate : RunDecelRate;

                Vector3 movement = speedDiff * accelRate;

                _playerRB.AddForce(movement, ForceMode.Force);  

                //Rotates bike
                Quaternion targetRotation = _thisObject.transform.rotation;
                _thisObject.transform.rotation = targetRotation;
                float targetAngle = Mathf.Atan2(movement.x, movement.z) * Mathf.Rad2Deg;
                targetRotation = Quaternion.Euler(0, targetAngle, 0);
                _thisObject.transform.rotation = Quaternion.Slerp(_thisObject.transform.rotation, targetRotation, _rotationSpeedBike * Time.deltaTime);

                //Rotates fork of bike
                float forkTargetAngle = Mathf.Atan2(movement.x, movement.z) * Mathf.Rad2Deg;
                Quaternion forkTargetRotation = Quaternion.Euler(-90, forkTargetAngle - 180, 0);
                _fork.transform.rotation = Quaternion.Slerp(_fork.transform.rotation, forkTargetRotation, _forkRotationSpeed * Time.deltaTime);

            }

            //controlls speed of bike and rotations on pedals and wheels
            Vector2 joystickvalue = _inputVectorOnBikeSpeed;

            // Calculate the rotation angle based on input
            float rotationAngle = (joystickvalue.x + previousJoystickValue.x) * _rotationSpeed * Time.deltaTime;
            float rotationAngleBike = (joystickvalue.x + previousJoystickValue.x) * _rotationSpeed * Time.deltaTime;
            float rotationAngleTurnWheels = (joystickvalue.x + previousJoystickValue.x) * _rotationSpeed * Time.deltaTime;



            // Update the current value based on the rotation angle
            _bikeSpeed += rotationAngle;
            _rotationSpeedBike += rotationAngleBike;
            _TB.RotationSpeed += rotationAngleTurnWheels;

            // Clamp the current value within the specified range
            _bikeSpeed = Mathf.Clamp(_bikeSpeed, _minValueSpeedBike, _maxValueSpeedBike);
            _rotationSpeedBike = Mathf.Clamp(_rotationSpeedBike, _minRotateValueSpeedBike, _maxRotateValueSpeedBike);
            _TB.RotationSpeed = Mathf.Clamp(_TB.RotationSpeed, _minWheelTurn, _maxWheelTurn);

            // Update the previous joystick value for the next frame
            previousJoystickValue = joystickvalue;
        }
        public override void ChangeState()
        {
            if (_changeVehicle && !_GM._hasJetPack && _timeLeft <= 0f)
            {
                _runner.SetState(typeof(IdleState));
            }

            if (_changeVehicle && _GM._hasJetPack && _timeLeft <= 0f)
            {
                _runner.SetState(typeof(JetPackState));
            }
            if (_GM._CamIsActive)
            {
                _runner.SetState(typeof(PauseState));
            }
        }
        public override void Exit()
        {
           
        }
        
    }

}

Bike state

Dialogue

For handling dialogue, I opted for Yarn Spinner due to its user-friendly interface. The ability to edit and test dialogue directly in Visual Studio greatly improved workflow efficiency.


Within the other scripts, I utilized Yarn Commands and Yarn Functions to facilitate seamless communication between Yarn and C#, ensuring that specific dialogues were triggered based on specific conditions.

Editing in Visual Studio Code

Result

Remaining programming

The remaining programming included creating signposts to guide the player, developing UI elements such as pause menus and tutorial guides, and implementing a feature that makes the houses move aside when obstructing the player's view.

Sign

Pause Menu

Moving House

Iterations

Throughout the project I consistently tested the game with external playtesters. The scriptable objects significantly simplified the iteration and debugging processes during playtesting. It was important to me to make the controls feel smooth and responsive which I succeeded with after numerous iterations.

Playtest Session

Final Thoughts

As a solo developer of “Doughy Dilemma” I discovered the limitations of big scripts, pushing me towards the state machine approach. This transition sharpened my skills in modular programming, emphasizing the virtues of scalability and maintainability. The iterative playtesting phase was a teachable moment in debugging and refinement. Using scriptable objects was a vital tool for efficient real-time adjustments. All in all, this project served as a technical bootcamp, expanding my toolkit as a game developer.