Shut In (Non Academic Project)

Game Image

Creator: Matthew Caldwell

I had been wanting to try my hand at game development for a while so I decided a horror game would make a great first project, as they are typically not mechanically complex. My goal was to have a working game in under 30 days. During the first week, I didn't spend much time working on the actual game, but rather spent time learning about the Unity engine. I created my own version of flappy bird and also purchased an 18 video course on the fundamentals of Unity from Udemy. Once I had gotten the ball rolling and felt a bit more familiar I started working on the game itself. I decided to go with an old school PSX style aesthetic because I think there is some visual charm in the way older games look. Also, it is much easier to make assets look more cohesive when using an older style.

Objective

- Collect 3 blue floppy disks
- Escape through the exit door

Mechanics

- Toggleable flashlight
- Ability to sprint for a limited duration

Download the game on Itch.io

Password: %xWr(;!7sEqW7Rxj:C0Z
monster

Procedural Monster Behavior & Good Game Design

I believe that good game design comes down to "less is more"; making a rewarding gameplay loop with as little mechanics as possible. Sure, there are cool games with very complex systems, but these games are only as fun as the fundamental mechanics are. Being that my mechanics are having a toggleable flashlight and the ability to sprint, I needed to ensure my monster's behavior to incentivizes the player to use them. The monster has three behavior states: Patrol, Attack, and Alert. Patrol involves the monster pathing randomly throughout the map, "looking for the player". If the player is within line of sight of the monster, the monster will enter Attack if the player's flashlight is on. Attack involves the monster chasing the player at a pace faster than walk speed but slower than sprint speed. This encourages the player to use their sprint ability. Alert state is triggered if the player turns their flashlight off. The monster cannot see in the dark and will instead walk aimlessly towards the sound of the player's footsteps. After the mosnter has been in Attack/Alert state for a certain amount of time, patrol will be triggered and the monster will run away and then continue patrolling. See next section for more details on how this works.

monster_ai.cs
public bool enableDebug = true;
public NavMeshAgent ai;
public List destinations;
public Animator aiAnim;
public float walkSpeed, chaseSpeed, minIdleTime, maxIdleTime, idleTime, sightDistance, catchDistance, chaseTime, minChaseTime, maxChaseTime, jumpscareTime;

public float timeSinceLastChase;  // Timer to track time since last chase
public float forceChaseThreshold;  // Threshold to force another chase


public bool walking, chasing;
public Transform player;
Transform currentDest;
Vector3 dest;
int randNum;
public int destinationAmount;
public Vector3 rayCastOffset;
public string deathScene;
public GameObject flashlight;

private bool isDead;

public AudioSource footsteps;
public AudioSource footsteps_medium;
public AudioSource footsteps_fast;
public AudioSource chase_scream_end;
public AudioSource stabbing_noise;

public Camera mainCamera;
public Camera deathCamera;

private float currentRadius;    // Responsible for flashlight off chasing
public float initialRadius = 10.0f; // Set the initial radius
public float radiusDecayRate = 1.0f; // Rate at which the monster gains dark sight
public float chaseCooldown = 5.0f; // Time in seconds before another chase can start
private float chaseCooldownTimer = 0; // Timer to track cooldown


public AudioSource chase_noise;
public Vector3 lastFlashlightPosition;
private bool previousFlashlightState; // To track the state change of the flashlight

//This generates an area around the monster's patrol points to prevent the monster from getting stuck running in circles
Vector3 GetRandomPointAroundDestination(Vector3 center, float radius)
{
    Vector3 randomDirection = Random.insideUnitSphere * radius + center;
    NavMeshHit navHit;
    if (NavMesh.SamplePosition(randomDirection, out navHit, radius, -1))
    {
        return navHit.position;
    }
    return center; // Fallback to the center if no valid point is found
}

//This function calculates the random running around player if player turns flashlight off during chase
Vector3 RandomNavSphere(Vector3 origin, float distance, int layermask) 
{
    Vector3 randomDirection = Random.insideUnitSphere * distance + origin;
    NavMeshHit navHit;
    NavMesh.SamplePosition(randomDirection, out navHit, distance, layermask);
    return navHit.position;
}

void Start()
{
    isDead = false;
    currentRadius = initialRadius; 
    walking = true;
    randNum = Random.Range(0, destinations.Count);
    currentDest = destinations[randNum];
    previousFlashlightState = flashlight.activeSelf;
    deathCamera.gameObject.SetActive(false);
}

void Update()
{
    if (chaseCooldownTimer > 0)
    {
        chaseCooldownTimer -= Time.deltaTime;
        walking = true;
    }
    
    // Increment the time since last chase timer
    if (!chasing) {
        timeSinceLastChase += Time.deltaTime;
    }

    // Force chase if the timer exceeds the threshold and the monster isn't already chasing
    if (timeSinceLastChase >= forceChaseThreshold && !chasing) {
        ForceChase();
    }
    
    Vector3 direction = (player.position - transform.position).normalized;
    RaycastHit hit;

    // Decrease currentRadius if flashlight is off and monster is chasing
    if (!flashlight.activeSelf && chasing)
    {
        lastFlashlightPosition = player.position;
        if (previousFlashlightState) // If flashlight was just turned off
        {
            if (!footsteps_fast.isPlaying)
            {
                footsteps_fast.Play();
            }

            chase_scream_end.Play();
            chase_noise.Stop();
            // Additional behavior if needed when flashlight turns off
        }

        currentRadius -= radiusDecayRate * Time.deltaTime;
        currentRadius = Mathf.Max(0, currentRadius);
    }

    // Increase currentRadius if flashlight is on, regardless of previous state, until it reaches initialRadius
    if (flashlight.activeSelf)
    {
        if (!chase_noise.isPlaying && chasing)
        {
            chase_noise.Play();
        }

        footsteps_fast.Stop();
        currentRadius += radiusDecayRate * Time.deltaTime;
        currentRadius = Mathf.Min(currentRadius, initialRadius);
    }

    // Detect player using raycast
    if (flashlight.activeSelf && chaseCooldownTimer <= 0){
        if (Physics.Raycast(transform.position + rayCastOffset, direction, out hit, sightDistance))
        {
            if (!chasing && hit.collider.gameObject.tag == "Player")
            {
                //CHASE TRIGGERED
                //Debug.Log("CHASE TRIGGERED");
                walking = false;
                StopCoroutine("stayIdle");
                StopCoroutine("chaseRoutine");
                StartCoroutine("chaseRoutine");
                chasing = true;
            }
        }
    }

    // chase logic
    if (chasing)
    {
        dest = flashlight.activeSelf ? player.position : RandomNavSphere(lastFlashlightPosition, currentRadius, -1);
        ai.destination = dest;
        ai.speed = chaseSpeed;
        aiAnim.ResetTrigger("walk");
        aiAnim.ResetTrigger("idle");
        aiAnim.SetTrigger("sprint");
        
        float distance = Vector3.Distance(player.position, ai.transform.position);
        if (distance <= catchDistance)
        {
            aiAnim.ResetTrigger("walk");
            aiAnim.ResetTrigger("idle");
            aiAnim.ResetTrigger("sprint");
            if (!isDead)
            {
                footsteps_medium.Stop();
                footsteps_fast.Stop();
                stabbing_noise.Play();
                aiAnim.SetTrigger("jumpscare");
                
                isDead = true;
            }
            
            mainCamera.gameObject.SetActive(false);
            deathCamera.gameObject.SetActive(true);
            StartCoroutine(deathRoutine());
            chasing = false;
        }
    }
    if (walking)
    {
        footsteps_medium.Play();
        dest = currentDest.position;
        ai.destination = dest;
        ai.speed = walkSpeed;
        aiAnim.ResetTrigger("sprint");
        aiAnim.ResetTrigger("idle");
        aiAnim.SetTrigger("walk");
        if (ai.remainingDistance <= ai.stoppingDistance)
        {
            aiAnim.ResetTrigger("sprint");
            aiAnim.ResetTrigger("walk");
            aiAnim.SetTrigger("idle");
            ai.speed = 0;
            StopCoroutine("stayIdle");
            StartCoroutine("stayIdle");
            walking = false;
        }
    }

    previousFlashlightState = flashlight.activeSelf;
}
void ForceChase() {
    StopCoroutine("stayIdle");
    StopCoroutine("chaseRoutine");
    StartCoroutine("chaseRoutine");
    chasing = true;
    timeSinceLastChase = 0;  // Reset the timer
}

IEnumerator stayIdle()
{
    footsteps_medium.Stop();
    idleTime = Random.Range(minIdleTime, maxIdleTime);
    yield return new WaitForSeconds(idleTime);
    walking = true;
    footsteps_medium.Play();
    randNum = Random.Range(0, destinations.Count);
    currentDest = destinations[randNum];
}

IEnumerator chaseRoutine()
{
    footsteps_medium.Stop();
    chase_noise.Play();
    chaseTime = Random.Range(minChaseTime, maxChaseTime);
    yield return new WaitForSeconds(chaseTime);
    // Removed the reset of currentRadius here to prevent it from resetting each time chase ends
    if (!chase_scream_end.isPlaying && chase_noise.isPlaying)
    {
        chase_scream_end.Play();
    }
    chase_noise.Stop();
    footsteps_fast.Stop();
    footsteps_medium.Stop();

    chaseCooldown = (float)(chaseCooldown * 0.9f);
    //forceChaseThreshold = (float)(forceChaseThreshold * 0.9f);

    chaseCooldownTimer = chaseCooldown;
    minChaseTime += 5;
    maxChaseTime += 5;
    walking = true;
    chasing = false;
    randNum = Random.Range(0, destinations.Count);
    currentDest = destinations[randNum];
}

IEnumerator deathRoutine()
{
    yield return new WaitForSeconds(jumpscareTime);
    
    SceneManager.LoadScene(deathScene);
}

void OnDrawGizmos() // Debugging function to draw the chase radius
{
    if (enableDebug && chasing)
    {
        Gizmos.color = Color.red;
        Gizmos.DrawWireSphere(lastFlashlightPosition, currentRadius);
    }
}

                    

Monster Behavior Cont.

One thing I learned from this experience is that conceptualizing how something behaves in your head is not fleshed out at all with respect to how concrete coding is. Your brain tends to fill in a lot of expectations on how the monster should act in certain situations. Additionally, creating a monster that is not easy enough to make the game boring while also not being hard enough to make the game unplayable can feel like balancing a coin on a string. So I decided to have the monster become more difficult to evade by increasing certain properties with each chase, such as chase duration, monster speed, and hearing accuracy in the dark. Line of sight is calculated by having a raycast vector shoot from the monsters position to the players position. An if statement then checks if the ray is colliding with anything, and if it isn't, then attack state is triggered. Above is code from monster_ai.cs, reach out to me for full game source code.