Runaway Robot - A Mobile Runner Game

This quarter, I worked with three other students on a mobile game called Runaway Robot. The game, made in Unity, is a side-scrolling runner featuring mechanics like gravity reversal. All of the music, all of the code, and a large portion of the art was made from scratch. The code is open source and can be found at https://github.com/Kahraymer/Runner-Game.

One interesting part of the game that I worked on is the jump mechanic, which has a couple of interesting features.

  1. The gravity and initial jump velocity are calculated from the desired maxJumpHeight (the height at the top of the arc), and maxJumpTimeToApex (time to reach the top of the jump arc). This approach is based off of Kyle Pittman’s GDC Talk. While it doesn’t result in physically accurate jumps, it lets us tune the jump arc more intuitively. For example, a “tight” jump has a small maxJumpTimeToApex and a “looser” jump has a long maxJumpTimeToApex. maxJumpHeight is determined by the size of obstacles, which in turn is constrained by the size of the screen and the size of the player on the screen.

  2. The jump controller allows the player to queue up a jump while they are falling, but before they actually hit the floor. Almost all games do this, since a player that is jumping repeatedly in a pattern might try to jump a few frames before the character actually hits the ground. We need to allow for some temporal flexibility so that the player doesn’t miss a jump, thus destroying their momentum. This is implemented below through the rebound boolean which, if true, triggers a jump immediately upon landing.

  3. The jump can be modulated depending on how long the player holds the jump button. This is implemented by temporarily increasing gravity when the button is released. Gravity is returned to normal once the player reaches the top of their arc. Unfortunately, this led to a situation in which not all “taps” had equal arcs, because some “taps” lasted for slightly more frames than others. To normalize all “taps,” I implemented a minJumpTime, where the jump cannot be terminated until airTime >= minJumpTime. This effectively gives us a min and a max jump height, so it is easy to design our obstacles around these contraints.

  4. I supported double jump, though we dropped this in the final game. In a pair of jumps, both jumps can be modulated, resulting in a wide variety of trajectories. Unfortunately, our player is too big on the screen. If we reduce jump height such that the player can never go off the top of the screen, than a single min-jump becomes so small that it is meaningless. Because of this, we decided to stick with a single, modulatable jump, as that still gave us a decent spread of jump arcs.

  5. The gravity can be inverted, so the jump controller accounts for that.

The code below is taken from PlayerController.cs, which implements the jumping mechanic.

public class PlayerController : MonoBehaviour {
  // These three tuneable values affect the 'feel' of the jump.
  [Tooltip("The height of a max jump.")]
  public float maxJumpHeight;
  [Tooltip("The time to apex of a max jump.")]
  public float maxJumpTimeToApex;
  [Tooltip("The minimum amount of time before a jump can be terminated.")]
  public float minJumpTime;

  // Enable or disable double jump.
  [Tooltip("Can the player double jump?")]
  public bool canDoubleJump;

  // This mask only matches "Ground" objects, so that we can't jump off a coin.
  public LayerMask groundMask;
  // This transform is a BoxCollider parented to the player which is used to
  // check the space under the player.
  public Transform groundCheck;

  // This field tracks the amount of time the player has spent in air after
  // jumping. Should be equal to 0 if the player is grounded or if the player
  // just started their second jump.
  private float airTime = 0.0f;

  // This enum tracks the current state in the jump.
  private enum JumpPhase { Grounded, PreJump, Rising, TerminatedRising, Falling }
  private JumpPhase jumpPhase = JumpPhase.Grounded;

  // Track whether or not the player should rebound when it hits the ground.
  private bool rebound = false;
  // Check whether or not we are on the second jump of a pair.
  private bool secondJump = false;

  private Rigidbody2D rigidBody;
  void Start () { this.rigidBody = GetComponent<Rigidbody2D> (); }

  // Track whether or not gravity is inverted.
  private bool inverted = false;
  public bool Inverted {
    get { return inverted; }
    set { inverted = value; }
  }

  // Update is called once per frame
  void Update () {
    bool jump = Input.GetButtonDown ("Jump");

    // Note that the PreJump phase is used because physics must be applied in
    // `FixedUpdate`, but inputs have to be collected in `Update`.

    if (canDoubleJump && !secondJump && jumpPhase != JumpPhase.Grounded && jump) {
        airTime = 0.0f; // Reset air-time.
        rebound = false; // Reset the rebound flag.
        secondJump = true; // We are on our second jump.
        jumpPhase = JumpPhase.PreJump; // Move to the PreJump phase.
    }

    if (jumpPhase == JumpPhase.Grounded && (jump || rebound)) {
        rebound = false; // Reset the rebound flag.
        secondJump = false; // We are on our first jump.
        jumpPhase = JumpPhase.PreJump; // Move to the PreJump phase.
    }

    // Queue up a jump if the player tries to jump while we are falling.
    if (jumpPhase == JumpPhase.Falling && jump) {
      rebound = true;
    }
  }

  void FixedUpdate() {
    // Calculate gravity.
    float gravity = (-2 * maxJumpHeight) / (maxJumpTimeToApex * maxJumpTimeToApex);
    if (inverted) gravity *= -1;
    if (jumpPhase == JumpPhase.TerminatedRising) gravity *= 3;

    // Calculate initial jump velocity.
    float jumpVelocity = (2 * maxJumpHeight) / maxJumpTimeToApex;

    // Apply the difference between real gravity and desired gravity.
    rigidBody.AddForce (new Vector2(0, gravity) - Physics2D.gravity);

    // Increment airTime.
    if (jumpPhase == JumpPhase.Grounded) airTime = 0.0f;
    else airTime += Time.fixedDeltaTime;

    // In the PreJump phase, simply apply the initial velocity.
    if (jumpPhase == JumpPhase.PreJump) {
      rigidBody.velocity = new Vector2 (
        rigidBody.velocity.x, inverted ? -jumpVelocity : jumpVelocity);
      jumpPhase = JumpPhase.Rising;
    }

    // During the Rising phase, check to see if the jump has been terminated,
    // upon which switch to the TerminatedRising phase where gravity is
    // temporarily stronger.
    if (jumpPhase == JumpPhase.Rising) {
      bool letgo = !Input.GetButton ("Jump");
      if (airTime >= minJumpTime && letgo)
        jumpPhase = JumpPhase.TerminatedRising;
    }

    if (inverted ? rigidBody.velocity.y > 0 : rigidBody.velocity.y < 0)
      jumpPhase = JumpPhase.Falling;

    if (jumpPhase == JumpPhase.Falling) {
      if (groundCheck.GetComponent<Collider2D>().IsTouchingLayers(groundMask))
        jumpPhase = JumpPhase.Grounded;
    }
  }
}