VESPER

2D Platformer · Unity2D · DOTween · Input & Particle Systems

Summary of the Project

Vesper was a 7-week game project at Yrgo. You can play it on Vesper ITCH.IO.

Vesper is a skill-based puzzle platformer where you play as a formless celestial being trapped in a physical form. Use the ability to change sizes to find your way home.


Main Mechanics

The three core mechanics are player movement, switching sizes, and the vent (labyrinth) movement. I was responsible for implementing each.

Player Movement

From the first prototype to the final version, I refined movement to be as smooth and responsive as possible. Below is a before‐and‐after comparison.

We used Unity’s Input System with events and delegates to handle precise, buffered jumps and movement:

Show Input functions

// Called when Move input is performed or canceled
public void Move(InputAction.CallbackContext ctx)
{
    moveInput = ctx.ReadValue;();
}

void OnJumpStarted(InputAction.CallbackContext ctx)
{
    startedJump = true;
    if (ctx.performed)
    {
        jumpBufferTimer = jumpBufferTime;
        jumpPressed = true;
    }
}

void OnJumpCanceled(InputAction.CallbackContext ctx)
{
    jumpBufferTimer -= jumpBufferTime;
    if (!ctx.performed && rb.velocity.y > 0)
    {
        rb.velocity = new Vector2(rb.velocity.x, rb.velocity.y * jumpCutOff);
        coyoteTimer = 0;
    }
}
          

These functions were bound like this:


// Example of binding
actions["Jump"].performed += OnJumpStarted;
actions["Jump"].canceled  += OnJumpCanceled;
          

Switching Sizes

Switching sizes changes the player’s scale and stats at runtime. We stored three presets (Big, Medium, Small) and simply applied the corresponding values when the player pressed a size key.

SizeStats Script

public class SizeStats : MonoBehaviour
{
    #region StatsLists
    List statsSmall, statsMedium, statsLarge;
    #endregion

    #region SizeParameters
    [Header("Size Parameters")]
    [Range(0,3)] public float sizeSmall  = 0.25f;
    [Range(0,3)] public float sizeMedium = 0.75f;
    [Range(0,3)] public float sizeLarge  = 1.25f;
    #endregion

    #region MovementStats
    [Header("Movement Stats")]
    [Range(0,100)] public float speedSmall  = 10;
    [Range(0,100)] public float speedMedium = 6;
    [Range(0,100)] public float speedLarge  = 4;

    [Space(10)]
    [Range(0,100)] public float accelerationSmall  = 30;
    [Range(0,100)] public float accelerationMedium = 20;
    [Range(0,100)] public float accelerationLarge  = 10;

    [Space(10)]
    [Range(0,100)] public float deaccelerationSmall  = 10;
    [Range(0,100)] public float deaccelerationMedium = 20;
    [Range(0,100)] public float deaccelerationLarge  = 30;
    #endregion

    #region JumpStats
    [Header("Jump Stats")]
    [Range(0,50)] public float jumpHeightSmall  = 4;
    [Range(0,50)] public float jumpHeightMedium = 6;
    [Range(0,50)] public float jumpHeightLarge  = 8;

    [Space(10)]
    [Range(0,100)] public float fallSpeedSmall  = 1;
    [Range(0,100)] public float fallSpeedMedium = 2;
    [Range(0,100)] public float fallSpeedLarge  = 3;

    [Space(10)]
    [Range(0,1)] public float jumpCutOffSmall  = 0.5f;
    [Range(0,1)] public float jumpCutOffMedium = 0.1f;
    [Range(0,1)] public float jumpCutOffLarge  = 0.005f;
    #endregion

    #region GroundCheckSizes
    [Header("Ground Check Sizes")]
    float groundCheckSizeSmallX, groundCheckSizeMediumX, groundCheckSizeLargeX;
    float groundCheckSizeSmallY, groundCheckSizeMediumY, groundCheckSizeLargeY;
    #endregion

    #region AirMultipliers
    [Header("Air Multipliers")]
    [Range(0,10)] public float airSpeedMultiSmall  = 0.9f;
    [Range(0,10)] public float airSpeedMultiMedium = 1f;
    [Range(0,10)] public float airSpeedMultiLarge  = 1f;

    [Space(10)]
    [Range(0,10)] public float airAccMultiSmall  = 1f;
    [Range(0,10)] public float airAccMultiMedium = 1f;
    [Range(0,10)] public float airAccMultiLarge  = 1f;

    [Space(10)]
    [Range(0,10)] public float airDecMultiSmall  = 0.9f;
    [Range(0,10)] public float airDecMultiMedium = 0.9f;
    [Range(0,10)] public float airDecMultiLarge  = 0.9f;
    #endregion

    #region LandingSfxOffsets
    [Header("Landing SFX Offsets")]
    public float landingSfxOffsetSmall  = 0.05f;
    public float landingSfxOffsetMedium = 0.1f;
    public float landingSfxOffsetLarge  = 0.5f;
    #endregion

    void Start()
    {
        UpdateStatValues();
    }

    public List ReturnStats(Sizes size)
    {
        if (size == Sizes.SMALL) return statsSmall;
        if (size == Sizes.BIG) return statsLarge;
        return statsMedium;
    }

    private void UpdateStatValues()
    {
        // (Populate stats lists as before…)
    }
}
          

Vent Movement

Vent (labyrinth) movement is a continuous motion from A to Z with no ability to stop mid-path—only to redirect. I implemented this gameplay feature to add a unique puzzle challenge.

VentMovement Script

public class VentMovement : MonoBehaviour, IReset
{
    public float moveSpeed = 5f;
    public bool canMoveRight, canMoveUp, canMoveDown, canMoveLeft;
    private Vector2 input, inputDirection, bufferedInput;
    public Vector3 prevPos;
    public float deadZone = 0.9f;

    Transform player;
    Rigidbody2D rb;
    InputActionAsset actions;
    RayCastHandler rayCastHandler;

    private void Start()
    {
        rayCastHandler = GetComponent();
        rb = GetComponent();
        player = PlayerController.player.transform;
        RegisterSelfToResettableManager();
    }

    private void OnEnable()
    {
        actions = GetComponent().actions;
        actions["Vent"].performed  += OnMove;
        actions["Vent"].canceled   += OnMoveCancel;
        actions.Enable();
        inputDirection = PlayerController.instance.moveInput;
    }

    private void OnDisable()
    {
        actions["Vent"].performed  -= OnMove;
        actions["Vent"].canceled   -= OnMoveCancel;
        input = Vector2.zero;
    }

    void Update()
    {
        MoveBuffer();
        Move();
    }

    void OnMove(InputAction.CallbackContext ctx)
    {
        input = ctx.ReadValue();
        MaxInput();
    }

    void MaxInput()
    {
        if (Mathf.Abs(input.x) > Mathf.Abs(input.y)) input.y = 0;
        else input.x = 0;
    }

    void OnMoveCancel(InputAction.CallbackContext ctx)
    {
        input = Vector2.zero;
    }

    void MoveBuffer()
    {
        if (input.x > deadZone)
        {
            if (canMoveRight) { inputDirection.x = 1; inputDirection.y = 0; bufferedInput = Vector2.zero; return; }
            else bufferedInput = new Vector2(1, 0);
        }
        if (input.x < -deadZone)
        {
            if (canMoveLeft) { inputDirection.x = -1; inputDirection.y = 0; bufferedInput = Vector2.zero; return; }
            else bufferedInput = new Vector2(-1, 0);
        }
        if (input.y > deadZone)
        {
            if (canMoveUp) { inputDirection.y = 1; inputDirection.x = 0; bufferedInput = Vector2.zero; return; }
            else bufferedInput = new Vector2(0, 1);
        }
        if (input.y < -deadZone)
        {
            if (canMoveDown) { inputDirection.y = -1; inputDirection.x = 0; bufferedInput = Vector2.zero; return; }
            else bufferedInput = new Vector2(0, -1);
        }
    }

    void Move()
    {
        rb.gravityScale = 0;
        canMoveUp    = rayCastHandler.smallTopIsFree;
        canMoveDown  = rayCastHandler.smallDownIsFree;
        canMoveLeft  = rayCastHandler.leftSide;
        canMoveRight = rayCastHandler.rightSide;

        if (bufferedInput != Vector2.zero)
        {
            bool canProceedX = (bufferedInput.x != 0) && (canMoveRight || canMoveLeft);
            bool canProceedY = (bufferedInput.y != 0) && (canMoveUp || canMoveDown);
            rb.velocity = canProceedX || canProceedY
              ? bufferedInput * moveSpeed
              : inputDirection * moveSpeed;
        }
        else
        {
            rb.velocity = inputDirection * moveSpeed;
        }

        if (Vector3.Distance(transform.position, prevPos) < 0.005f)
            bufferedInput = Vector2.zero;

        prevPos = transform.position;
    }
}