Gameplay Programmer

Rewinder

About

Rewinder is a first-person runner game with a time travel mechanic. The objective of the game is to escape from an abstract prison by travelling through different worlds connected by portals. To open them, the player must collect collectables set in acrobatic levels. In the long run, the player will aim for the highest score on each level, and for the overall time spent playing. The less time he spends to finish a level, the better his score will be.

Project Info

  • Role: Gameplay Programmer
  • Team Size: 6
  • Time frame: 6 month
  • Engine: Unity 3D

Introduction

On Rewinder, I acted as the main technical owner of the player systems. My responsibilities included defining the game architecture, implementing the character controller, and maintaining overall technical stability throughout development.

The team consisted of 2 programmers and 4 artists, which meant architectural decisions mattered early. We needed a setup that allowed programmers to work in parallel, iterate quickly, and debug issues without constantly stepping on each other’s work.

During this project, I became particularly interested in the State Pattern, and decided to use it as the foundation for the player controller. This article walks through that decision, the resulting architecture, and a concrete gameplay challenge: implementing wall running and wall jumping in a way that felt responsive and natural.

Why the State Pattern?

Before touching implementation, I had two main concerns, clarity and debuggability with extensibility.

I wanted clarity over cleverness because player controllers tend to devolve into large conditional blocks as features accumulate. I wanted a structure where each behavior lived in a clearly defined place.

With multiple people working on the codebase, it needed to be obvious where a behavior lived and why it was executed.

The State Pattern fit those constraints well. Each player behavior (idle, run, jump, wall run, wall jump, climb, fall…) could exist in isolation, while a central state machine handled execution.

I deliberately chose transitions handled inside states, not through a global transition table. This kept logic close to behavior and reduced indirection.

I also chose an interface-based approach instead of inheritance-heavy hierarchies. This avoided rigid coupling and made states easier to reason about.

State System Implementation

At its core, the system consists of:

  • A StateMachine owned by the player.

  • A shared IState interface implemented by all concrete states.

Each state defines its own lifecycle:

namespace InadeStd.Player.StateSystem
{
    /// <summary> Base interface for a state </summary>
    public interface IState
    {
        /// <summary> Called once, when we enter the state </summary>
        void Enter();
        
        /// <summary> Called each frame, when we are in the state </summary>
        void Execute();
        
        /// <summary> Called each fixed frame, when we are in the state </summary>
        void FixedExecute();
        
        /// <summary> Called once, when we exit the state </summary>
        void Exit();

    }
}

The state machine itself is intentionally minimal:

using UnityEngine;

namespace InadeStd.Player.StateSystem
{
    /// <summary> Base class for a state machine </summary>
    public class StateMachine
    {
        ///<summary> The current state we're in </summary>
        private IState m_currentState;

        /// <summary> Method to change state. It needs a new state as parameter</summary>
        /// <param name="p_newState"> The new state we want </param>
        public void ChangeState(IState p_newState)
        {
            m_currentState?.Exit();

            m_currentState = p_newState;
            m_currentState.Enter();
        }
        
        /// <summary>The update method of the state machine. It should be called each frame.</summary>
        public void Update()
        {
            m_currentState?.Execute();
        }

        public void FixedUpdate()
        {
            m_currentState?.FixedExecute();
        }
        
    }
}

This simplicity was intentional. The state machine doesn’t decide anything, it delegates execution. All gameplay decisions live inside the states themselves, making bugs easier to track and behaviors easier to modify.

Wall Running & Wall Jumping: A Gameplay-Driven Challenge

One of the more interesting challenges on Rewinder was implementing wall running and wall jumping in a way that felt fluid rather than mechanical.

From a design standpoint, the player needed to:

  • Detect walls reliably

  • Distinguish between climbing and wall running

  • Move along the wall with a sense of momentum and curvature

  • Transition cleanly into jumps or falls

Wall Detection Strategy

To infer player intent without explicit input, I implemented a multi-raycast approach. A small set of raycasts is sent from the player’s position, slightly offset, checking:

  • Directly forward (climb)

  • Left and right (wall run)

  • Forward-left and forward-right (corner cases)

This allowed the controller to classify the situation based on contact geometry rather than button presses.

/// <summary>
/// This method launches rays in the different directions we defined. If it touches something, we launch the wall run state.
/// </summary>
public void WallRunChecker()
{
    Vector3 p_startB = new Vector3(transform.position.x, transform.position.y + 0.1f, transform.position.z);
    m_isClimbing = Physics.Raycast(transform.position, transform.forward, 0.9f, m_playerDataVar.m_wallrunLayer);
    m_touchLeft = Physics.Raycast(p_startB, -transform.right, out RaycastHit p_hitL, 0.7f, m_playerDataVar.m_wallrunLayer);
    m_touchRight = Physics.Raycast(p_startB, transform.right, out RaycastHit p_hitR, 0.7f, m_playerDataVar.m_wallrunLayer);
    m_touchLeftForward = Physics.Raycast(p_startB, -transform.right + transform.forward, out RaycastHit p_hitLF, 0.7f, m_playerDataVar.m_wallrunLayer);
    m_touchRightForward = Physics.Raycast(p_startB, transform.right + transform.forward, out RaycastHit p_hitRF, 0.7f, m_playerDataVar.m_wallrunLayer);

    //Left side
    if(m_touchLeftForward && m_touchLeft)
    {
        /*if(m_touchLeftForward)
            m_currentWallRunSurfaceNormal = p_hitLF.normal;
        else if (m_touchLeft)*/
        m_currentWallRunSurfaceNormal = p_hitL.normal;
        
        // Get the 90° "direction" based on which side the wall is. This is used to fake gravity.
        m_wallPerpendicularAngle = 90.0f;
        // Set the direction in which the vector will be rotated to go upwards
        m_currentWallrunAngle = (m_moveDirection.y >= 0 ? -m_playerDataVar.m_wallrunAngle : -m_playerDataVar.m_wallrunAngleNegative);
        
        m_stateMachine.ChangeState(m_wallRunState);
    }
    //Right side
    else if (m_touchRightForward && m_touchRight)
    {
        /*if(m_touchRightForward)
            m_currentWallRunSurfaceNormal = p_hitRF.normal;
        else if (m_touchRight)*/
        m_currentWallRunSurfaceNormal = p_hitR.normal;
        
        // Get the 90° "direction" based on which side the wall is. This is used to fake gravity.
        m_wallPerpendicularAngle = -90.0f;
        // Set the direction in which the vector will be rotated to go upwards, depending on the entrance Y velocity
        m_currentWallrunAngle = (m_moveDirection.y >= 0 ?m_playerDataVar.m_wallrunAngle : m_playerDataVar.m_wallrunAngleNegative);
        
        m_stateMachine.ChangeState(m_wallRunState);
    }
    else if(m_isClimbing)
    {
        m_playerAnim.ResetTrigger("ResumeRun");
        m_playerAnim.SetTrigger("ClimbWall");
        m_stateMachine.ChangeState(m_climbState);
    }
}

The important point here isn’t the raycasts themselves, but the intent inference: the controller decides what the player wants to do based on spatial context.

Moving Along the Wall

Once in the wall run state, movement is calculated relative to the wall’s surface normal.

The direction is projected along the wall and slightly rotated upward, then it gradually curve downward to simulate loss of momentum.

This produces a trajectory resembling a convex curve, giving the wall run a natural rise-and-fall feeling rather than a flat glide.

/// <summary> Method to apply the movement of the player along a wall </summary>
/// <param name="p_hitNormal"> The normal vector of the wall we're moving on</param>
private void MoveAlongWall(Vector3 p_hitNormal)
{
    // Check if we're still on the wall
    m_inWallRun = Physics.Raycast(m_owner.transform.position, -p_hitNormal, 0.7f,
        m_owner.m_playerDataVar.m_wallrunLayer);
    
    if (m_inWallRun)
    {
        StateCheck.OnWall = true;
        
        // Set the wall run speed
        m_owner.m_currentWallrunSpeed = m_owner.m_moveDirection.magnitude + m_owner.m_playerDataVar.m_wallrunEntranceSpeedBoost;
        m_owner.m_currentWallrunSpeed += m_owner.m_playerDataVar.m_wallrunSpeedBoost; 
        
        // Clamp it so we don't go too fast
        m_owner.m_currentWallrunSpeed = Mathf.Clamp(m_owner.m_currentWallrunSpeed, 0.0f, m_owner.m_playerDataVar.m_maximumWallrunSpeed);

        // Set the base wall direction and the move direction (which is wall direction rotated to go slightly upwards)
        Vector3 p_wallForwardDirection = Vector3.ProjectOnPlane(m_owner.transform.forward, p_hitNormal).normalized;
        m_owner.m_wallrunBaseDirection = Quaternion.AngleAxis(m_owner.m_currentWallrunAngle, p_hitNormal) * p_wallForwardDirection;

        // Calculate the version of the vector that adds fake gravity
        Vector3 p_direction;
        if (m_owner.m_wallrunSlowdownTime < 1.0f)
        { 
            // Set direction
            p_direction = Vector3.Lerp(m_owner.m_wallrunBaseDirection, Quaternion.AngleAxis(m_owner.m_wallPerpendicularAngle, p_hitNormal) * m_owner.m_wallrunBaseDirection, m_owner.m_wallrunSlowdownTime);
            // Augment the slowdown factor
            m_owner.m_wallrunSlowdownTime += Time.deltaTime * m_owner.m_playerDataVar.m_wallrunSlowdown;
        }
        else { p_direction = Quaternion.AngleAxis(m_owner.m_wallPerpendicularAngle, p_hitNormal) * m_owner.m_wallrunBaseDirection; } // Set direction
        
        // Move along the vector
        m_owner.m_moveDirection = p_direction * m_owner.m_currentWallrunSpeed;
        m_owner.m_controller.Move(m_owner.m_moveDirection*Time.deltaTime);
        
        if (Input.GetButtonDown(m_owner.m_playerDataVar.m_jumpKey)) { m_owner.m_stateMachine.ChangeState(m_owner.m_wallJumpState); } // Check if we jump to change to wall jump
    }
    else { m_owner.m_stateMachine.ChangeState(m_owner.m_fallFromWallRunState); } //Check if we've reach the end of the wall to change to a fall
    
}

By keeping this logic contained inside the wall run state, tuning and iteration became much easier. Gameplay adjustments didn’t risk breaking unrelated movement code.

Architectural Choices

Because the project involved multiple programmers, modularity was a priority. The main decision we took was to have Assembly Definitions for each modules. It helped to enforce boundaries and reduce accidental dependencies.

Another decision we took was to use Scriptable Objects for shared configuration data. It allowed designers and us the programmers to tweak values without code changes.

We also used a lightweight GameManager singleton strictly for systems that needed to persist across scenes.

The state-based player architecture also paid off here: adding or adjusting behaviors rarely required touching unrelated systems, which reduced merge conflicts and debugging time.

What I Learned

This project was my first experience acting as a technical decision-maker for a shared codebase.

I learned that clear structure beats clever solutions when working in a team and iteration reveals design flaws faster than theory.

Separating fast experimentation from structural stability is powerful when teammates have different working styles. And since my programming team mate was excellent at fast experimentation, the work between us was pleasant and productive.

From a gameplay perspective, Rewinder was humbling. Many mechanics that looked good on paper only felt right after extensive playtesting and tuning. Learning when to let go of an idea or simplify it, was just as important as implementing it.

Rewinder was both a technical and collaborative learning experience. It reinforced my interest in building systems that are not only functional, but understandable, adaptable, and pleasant to work with, especially in a team setting!

If you’d like to try the game yourself, you can find it here: