Momentum

The time continuum is falling apart, it is up to you to find the stolen time and release it back into the timeline.

Contributions


Content Owner| Game Design |Narrative Design and Player Guidance | Technical Level Design |Prototype |C# Scripting

Development time: 7 weeks | Engine: Unity | Team size: 12

Additional tools: P4V | GitHub | Photoshop | Audacity

Download Momentum: Itch.io

Content owner


Coherent and consistent design is about more than just people following the vision of the lead: It’s about finding what we, as a team, can build together based on the set requirements of our project.

In this particular project, the main challenge was to figure out how we best, as a team and with our available skillsets, could create a game in which the narrative was communicated by the atmosphere and the gameplay, rather than through text and dialogue.

Modular approach

Since it is a puzzle game and I knew that finding our puzzles and pacing would be the hardest in this particular project, I thought that a modular approach to both mechanics as well as art assets would work the best. That way, we would have more time to work on the puzzles, and modifications down the line would be easier to make if needed. This turned out to be our saving grace, because if we would not have gone for this modular approach, it is very possible that we would not have finished our game, as we did end up making very substantial changes to our mechanics and levels quite late in our project.

Kill your darlings

Originally, we did have a companion planned as well. The companion would communicate to the player through messages on a display on the player’s arm. However, this is a part of the game that I decided to scrap, as it took away more from the game experience and the mechanics than the other way around.

It was a tough decision to make as both I and several other people in the team really liked the companion, but it was needed in order for the game to feel consistent with the atmosphere of the surroundings.

Problem solving

As I have prior experience in working with the Unity engine, I spent quite a lot of time on problem solving when other people in the team ran into issues, especially when it came to the integration of animations, as well as scripting sounds which are dependent on the player movement and position.

Game Design

Time is a resource

Our core mechanic revolves around the concept of time being a resource. Instead of electricity, the environment is run on the forces of time. The player has a tool which can control their immediate surroundings – but the tool has limited power, and the player can only control a limited amount of objects at the same time.

Objects which run on time

Momentum has three different kinds of objects that the player can control. They all affect one another, which needs to be considered when solving puzzles. The player is introduced to each of them at a time, and later on, they are all used together in more complex challenges.

Platforms

Platforms with two states and positions.

These are found in puzzles in which it’s important that the player considers in which order they need to activate objects.

Moving blocks

Moving blocks that can be stopped in their motion.

These are found in puzzles which challenges the players ability in precision and platforming.

Jump pads

Jump pads which can be activated or deactivated.

These are used in puzzles where timing and speed is of high importance.

Narrative and Player Guidance

Premise

Momentum is set in a place where the technology is run on the forces of time. In order to sustain it, time has been harvested from several timelines. Due to this harvest, the time continuum has started to fall apart. It is up to the player to find the time vault and release time back into the world so the time continuum can heal before it is too late. To their aid, they have a tool which can directly control the time energy the technology is run on. But the tool’s power is not unlimited and it cannot maintain the control of all objects at once.

Player Guidance

The Narrative design in this game has two main purposes. It’s used for navigational purposes in the levels, as well as to give the player a sense of purpose in this strange, dark world.

Atmospheric audio & Light

In order to guide the player and strengthen the atmosphere, guiding lights as well as 3D-sounds which an increasingly erratic nature are used through the game. I was in charge of implementing these, as well as making quite a few of them.

Moving world

I made all of the environmental animations in the game using Unity’s own animation tool and the assets created by 3D.

At important moments in the game, the world will start moving. Old paths will be closed off or destroyed, and new ones will reveal themselves to the player.

This was done both for navigational purposes, as well as to increase the pacing and make the player feel like the world is crumbling more and more as they progress further in the game.

Speaker messages

In our original plan, we also had a companion that would communicate some key messages to the player.

However, since we scrapped the companion, I had to do something else in order to make up for it. I decided that a cold message coming from a speaker would suit our atmosphere the best.

Technical Level Design

Triggering events

As our game are reliant on different events and specific scripts triggering in the right order, I decided to focus on the technical aspects of the level design, so Marco could focus on the puzzles and the areas.

I did this by using a combination of the modular event system that our programmers made, as well as by writing some custom scripts for certain transitions and key events.

These triggers lights, animations, speakers, 3D-sounds, spawns new objects, turns other triggers on and off, activates camera shakes, opens some paths and closes old ones. To name some.

Prototype

I made two prototypes for the team early on. The first iteration of the core mechanic: The ability to speed things up and down, as well as the affected objects being able to affect other objects in the game.

The prototype was pretty simple, I set up some easy to use tools so the other designers could start exploring the mechanic and making puzzles with it, so that we would be able to make a decision regarding how we wanted to evolve the mechanic going forward.

One thing which quickly became apparent is that we would need to introduce quite a few restrictions to the mechanic in order to make it more feasible to create puzzles, as well as prevent a gameplay which was more chaotic than the one we desired. In my original prototype, the player would cause a time paradox if they used their abilities too much, we later decided to change that and put a restriction on how many objects the player can control at the same time.

The other prototype was a display which the player could communicate with. It received strings from other objects in the level, animated a flip of the display, and then wrote out the message using a Coroutine.

C# Scripting

I have chosen to include some of the scripts I made for the game, mainly those used in the prototype to show where we started. Most of the scripts I made that are used in our current game are very simple sound and/or animation scripts, as very little scripting was needed to be done by us designers after our programmers had developed tools for us.

C# Scripting

In order to increase the tension, I made a script which plays footstep sounds when the player is walking on a surface, as well as a script which plays an audio which increases in volume as the player is falling further.


public class RandomFootsteps : MonoBehaviour

    
{

    CharacterController _cc;
    public AudioSource fallingSound;
    [SerializeField] AudioSource _footStep;
    
    void Start()
    {
        _cc = GetComponent();

    }

    
    void Update()
    {


        bool isGrounded = GetComponent().IsGrounded();
        if (isGrounded == true)

        {
            fallingSound.Stop();

            if (isGrounded == true && _cc.velocity.magnitude > 2f && _footStep.isPlaying == false && _footStep && Input.anyKey)
            {
                _footStep.volume = Random.Range(0.35f, 0.45f);
                _footStep.pitch = Random.Range(0.52f, 0.6f);
                _footStep.Play();
            }
        }

        else if (isGrounded == false && fallingSound.isPlaying == false)
        {
            fallingSound.Play();
        }
    }
}

This script receives a float value, and applies that as the speed along a path. The path is made using an array of game objects in the level.


[RequireComponent(typeof(Rigidbody))]

public class Target : MonoBehaviour
{
    public float movementSpeed;

    
    [Tooltip("First create size of the path, then create empty objects and put them in the fields named element 0, element 1 etc.")]
    public Transform[] targetPoints;

    [Header("Min and max time paradox settings")]
    public float maxSpeed = 100f;
    public float minSpeed = 0f;

    private int current;
    private bool movingPlayer;

    Rigidbody requiredRigidBody;

    void Start()
    {
        requiredRigidBody = GetComponent();
        requiredRigidBody.useGravity = false;
        requiredRigidBody.isKinematic = true;
    }

    public void SpeedDown (float speedAmount)
    {
        movementSpeed -= speedAmount;
        Debug.Log(movementSpeed);

        if (movementSpeed < minSpeed)
        {
            movementSpeed = 0f;
            //Paradox();
        }
    }

    public void SpeedUp (float speedAmount)
    {
        movementSpeed += speedAmount;
        Debug.Log(movementSpeed);

        if (movementSpeed >= maxSpeed)
        {
            Paradox();
        }
    }

    void Update()
    {

         if (transform.position != targetPoints[current].position) //Move until you reach the current object/waypoint
            {
                Vector3 pos = Vector3.MoveTowards(transform.position, targetPoints[current].position, movementSpeed * Time.deltaTime);
                GetComponent().MovePosition(pos);
            }

         else current = (current + 1) % targetPoints.Length; //Object waypoint reached, move to the next object

    }

    private void OnCollisionEnter(Collision col)
    {
        movementSpeed = 0f;
        Debug.Log("I'm a collider!");
    }


    void Paradox ()
    {
        Debug.Log("You created a time paradox!");
        //movementSpeed = 50f;
    }




}

This script defines the rotation of and object. It also receives a float value, and applies that value to the rotoation speed. During this stage of the prototype phase, we also tested if a time paradox which made objects react in an unexpected way could be an interesting restriction in the mechanic.


[RequireComponent(typeof(Rigidbody))]
public class TargetRotator : MonoBehaviour
{

    public float movementSpeed;

    [Header("Select ONLY ONE axis it should rotate on.")]
    public bool xAxis;
    public bool yAxis;
    public bool zAxis;

    [Header("Min and max time paradox settings")]
    public float maxSpeed = 10f;
    public float minSpeed = 0f;

    Rigidbody requiredRigidBody;

    public void SpeedDown(float speedAmount)
    {
        movementSpeed -= speedAmount;
        Debug.Log(movementSpeed);

        if (movementSpeed < minSpeed)
        {
            movementSpeed = 0f;
            
        }
    }

    public void SpeedUp(float speedAmount)
    {
        movementSpeed += speedAmount;
        Debug.Log(movementSpeed);

        if (movementSpeed >= maxSpeed)
        {
            ParadoxRotate();
        }
    }

    private void Start()
    {
        requiredRigidBody = GetComponent();
        requiredRigidBody.useGravity = false;
        requiredRigidBody.isKinematic = true;
    }

    void Update()
    {
        if (xAxis == true)
        {
            transform.Rotate(movementSpeed, 0, 0);
        }

        else if (yAxis == true)
        {
            transform.Rotate(0, movementSpeed, 0);
        }

        else if (zAxis == true)
        {
            transform.Rotate(0, 0, movementSpeed);
        }
        
    }


    void ParadoxRotate()
    {
        Debug.Log("You created a time paradox!");
        
    }
}

This script receives and sends a float value, it doesn't apply it to either rotation or a movement path.


public class Sender : MonoBehaviour
{
    public GameObject[] receiverObjects;

    public float speedDown;
    public float speedUp;

    public float rotateSpeedDown;
    public float rotateSpeedUp;

    
       

    public void SpeedDown (float speedDown)
    {
        foreach (GameObject receiverObject in receiverObjects)
        {
            Debug.Log(receiverObject.name + "Received the signal to slow down!");
            Target targetHit = receiverObject.GetComponent();
            if (targetHit != null)
            {
                targetHit.SpeedDown(speedDown);
            }

            TargetRotator targetHitRotator = receiverObject.GetComponent();
            if (targetHitRotator != null)
            {
                targetHitRotator.SpeedDown(speedDown);
            }

            Sender senderHit = receiverObject.GetComponent();
            if (senderHit != null)
            {
                senderHit.SpeedDown(speedDown);
            }
        }
    }

    public void SpeedUp (float speedUp)
    {
        foreach (GameObject receiverObject in receiverObjects)
        {
            Debug.Log(receiverObject.name + "Received the signal to speed up!");
            Target targetHit = receiverObject.GetComponent();
            if (targetHit != null)
            {
                targetHit.SpeedUp(speedUp);
            }

            TargetRotator targetHitRotator = receiverObject.GetComponent();
            if (targetHitRotator != null)
            {
                targetHitRotator.SpeedUp(speedUp);
            }

            Sender senderHit = receiverObject.GetComponent();
            if (senderHit != null)
            {
                senderHit.SpeedUp(speedUp);
            }
        }
    }

    void Start()
    {
        foreach (GameObject receiverObject in receiverObjects)
        {
            Debug.Log(receiverObject.name + "reported in!");
        }
        
}


}

The script I made for the tool, so it could send a float value to the receivers.


public class RaycastGun : MonoBehaviour
{

    public float speedDown = 5f;
    public float speedUp = 5f;

    public float rotateSpeedDown = 0.5f;
    public float rotateSpeedUp = 0.5f;

    public float gunRange = 100f;

    public Camera fpsCam;



   


    void Update()
    {
        if (Input.GetButtonDown("Fire1"))
        {
            Shoot();
        }

        if (Input.GetButtonDown("Fire2"))
        {
            ShootTwo();
        }
    }

    void Shoot()
    {
        RaycastHit hit;
        if (Physics.Raycast(fpsCam.transform.position, fpsCam.transform.forward, out hit, gunRange))
        {
            Debug.Log(hit.transform.name);

            Target targetHit = hit.transform.GetComponent();
            if (targetHit != null)
            {
                targetHit.SpeedDown(speedDown);

            }

            TargetRotator targetHitRotator = hit.transform.GetComponent();
            if (targetHitRotator != null)
            {
                targetHitRotator.SpeedDown(rotateSpeedDown);

            }

            Sender senderHit = hit.transform.GetComponent();
            if (senderHit != null)
            {
                senderHit.SpeedDown(speedDown);
            }

        }

    }

    void ShootTwo()
    {
        RaycastHit hit;
        if (Physics.Raycast(fpsCam.transform.position, fpsCam.transform.forward, out hit, gunRange))
        {
            Debug.Log(hit.transform.name);

            Target targetHit = hit.transform.GetComponent();
            if (targetHit != null)
            {
                targetHit.SpeedUp(speedUp);

            }

            TargetRotator targetHitRotator = hit.transform.GetComponent();
            if (targetHitRotator != null)
            {
                targetHitRotator.SpeedUp(rotateSpeedUp);
            }

            Sender senderHit = hit.transform.GetComponent();
            if (senderHit != null)
            {
                senderHit.SpeedUp(speedUp);
            }
        }
    }
}

This component is placed on a trigger and sends a string to the display as well as activates the animation of the display flipping up to show the message.


public class SendString : MonoBehaviour
{
    public string companionMessage;
    public GameObject textDisplay;
    public GameObject animatedDisplay;
    private GameObject _thisObject;
    private bool hasSentMessage;
    public float waitForText;

    private void Start()
    {
       
        _thisObject = this.gameObject;
    }

    private void OnTriggerEnter(Collider other)
    {

        if (hasSentMessage == false)
        { 
            
            SendCompanionMessage(companionMessage);
            hasSentMessage = true;
            
        }

    }
    
 

    public void SendCompanionMessage (string companionMessage)
    {
        DisplayAnimations displayAnimation = animatedDisplay.GetComponent();
        displayAnimation.GoUp();

        PrintText messageReceiver = textDisplay.GetComponent();
        messageReceiver.WriteOutMessage(companionMessage);
        
    }

    public void Deactivate ()
    {
        _thisObject.SetActive(false);
    }



}

This script writes the message in the display.


public class PrintText : MonoBehaviour
{
    public TMP_Text m_TextComponent;
    public string[] sentencesCompanion;
    private int _index;
    private int _eventindex;

    private string _annoyingSpace = " ";

    public bool sentences;
    public bool events;

    public string[] eventOne;

    public float typingSpeed = 0.02f;
    

        public void WriteOutMessage (string companionMessage)
    {
        
        Debug.Log(companionMessage);
        m_TextComponent.text = "";
        StartCoroutine(TypeSentence(companionMessage));
        m_TextComponent.text = "";
    }


    IEnumerator TypeSentence(string sentence)
    {
        foreach (char letter in sentence.ToCharArray())
        {
            m_TextComponent.text += letter;
            yield return new WaitForSeconds(typingSpeed);
        }
    }



    public void NextSentence()
    {
        Debug.Log("Sentences wrote");
        
        if (_index < sentencesCompanion.Length - 1) 
        {
            _index++;
            m_TextComponent.text = "";
            StartCoroutine(TYPE()); 
            m_TextComponent.text = ""; 
        }
    }

    public void StartBanter()
    {
        
        Debug.Log("P wrote");


        if (_eventindex < eventOne.Length - 1) 
        {
            _eventindex++;
            m_TextComponent.text = "";
            StartCoroutine(TYPE()); 
            m_TextComponent.text = ""; 
        }
    }




    IEnumerator TYPE()
    {


       
        if (sentences == true)
        {
            foreach (char letter in sentencesCompanion[_index].ToCharArray())
            {
                m_TextComponent.text += letter;
                yield return new WaitForSeconds(typingSpeed);
            }
        }

        if (events == true)
        {
            foreach (char letter2 in eventOne[_eventindex].ToCharArray())
            {
                m_TextComponent.text += letter2;
                yield return new WaitForSeconds(typingSpeed);
            }
        }

        
    }



    void Awake()
    {
        m_TextComponent = GetComponent();
        
    }



    
    void Update()
    {


        if (Input.GetKeyDown(KeyCode.N))
        {
            sentences = true;
            events = false;
            NextSentence();
        }

        
        if (Input.GetKeyDown(KeyCode.P))
        {
            events = true;
            sentences = false;
            StartBanter();
        }

    }
}



public class DisplayAnimations : MonoBehaviour
{
    private Animator _displayControllerAnimator;

    private bool _isGoingUp = false;
    private bool _isGoingDown = false;
    private bool _isDisplayMovementActive = false;

    


    void Start()
    {
        _displayControllerAnimator = GetComponent();
    }

    
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.F) && !_isGoingUp)
        {
            _isDisplayMovementActive = true;
            GoUp();
        }

        else if (Input.GetKeyDown(KeyCode.F) && !_isGoingDown)
        {
            _isDisplayMovementActive = false;
            GoDown();
        }
    }

    public void GoUp ()
    {
        _displayControllerAnimator.SetBool("isGoingUp", true);
        _displayControllerAnimator.SetBool("isGoingDown", false);
        _isGoingUp = true;
        _isGoingDown = false;

    }

    public void GoDown ()
    {
        _displayControllerAnimator.SetBool("isGoingDown", true);
        _displayControllerAnimator.SetBool("isGoingUp", false);

        _isGoingDown = true;
        _isGoingUp = false;
    }

}