← Back to All Projects

Dike Keepers

June 2024 10/10

A First Person Shooter with a unique spin on the zombies formula. As a new recruit, it is your job to keep tranquilizing Muskrats and maintain the stability of the Dike.

  • Unity
  • C#
  • FPS
  • Game Design
Dike Keepers Screenshot

Project Overview

Dike Keepers is a first person movement shooter I developed as part of a 10-week game development course, where I worked as the lead programmer alongside 3 other students. The game challenges players to defend Dutch Dikes (basically dams, but with water on only one side) from rising muskrats that want to nest inside the dike, but in doing so destroy the integrity of the dike.

I was responsible for implementing the core gameplay systems including the wave-based enemy mechanics, physics systems, upgrade and store economy and the enemy pathfinding AI. The project required tight integration with Unity's tilemap system and custom enemy behaviour to account for walled off areas that would become available later in gameplay.

My Role

Lead Programmer

Developed all gameplay mechanics, enemy AI, and core systems using C#

What Made This Project Successful

What makes Dike Keepers successful is its unique theme and the satisfying movement shooter gameplay. Instead of enemies just walking toward a point, the "enemy" are rodents that path-find through physics-enabled rooms, creating dynamic and unpredictable patterns.

The resource economy is well-balanced, with players earning currency by successfully defending waves but also having to spend resources on repairs and upgrades to go further.

The upgrade system is particularly engaging — players can upgrade their weapon or repair the dike to increase its health, add reinforcement, or cover muskrat attack points using the games physics to prevent the enemies from reaching them for longer.

Code Highlights

Noteworthy implementations and solutions

Player Movement Code

Comprehensive Physics-based Movement with Dynamic Parkour

csharp - PlayerMovementImproved.cs View on GitHub →
using System;
using UnityEngine;

public class PlayerMovementImproved : MonoBehaviour {

    [Header("Assignables")]
    [Tooltip("This is a reference to the MainCamera object, not the parent of it.")]
    public Transform playerCam;
    [Tooltip("The Camera Holder not the main camera itself")]
    public GameObject cam;
    [Tooltip("Reference to the Gun, not the container")]
    public GameObject gun;
    [Tooltip("Reference to orientation object, needed for moving forward and not up or something.")]
    public Transform orientation;
    [Tooltip("What is the Settings Panel to toggle with Tab")]
    public GameObject canvas;
    [Tooltip("LayerMask for ground layer, important because otherwise the collision detection wont know what ground is")]
    public LayerMask whatIsGround;
    private Rigidbody rb;

    [Header("Interaction Bools")]
    public bool busy;
    public bool upgrading;
    public bool escaped;
    public bool escaping;
    public bool buying;
    public bool aiming;

    [Header("Rotation and look")]
    private float xRotation;
    [Tooltip("mouse/look sensitivity")]
    public int sensitivity = 50;
    private float sensMultiplier = 1.5f;
    private Vector2 lookInput;

    [Header("Movement")]
    [Tooltip("additive force amount. every physics update that forward is pressed, this force (multiplied by 1/tickrate) will be added to the player.")]
    public float moveSpeed = 4500;
    private Vector2 walkInput;
    [Tooltip("maximum local velocity before input is cancelled")]
    public float maxSpeed = 20;
    [Tooltip("normal countermovement when not crouching.")]
    public float counterMovement = 0.175f;
    private float threshold = 0.0001f;
    [Tooltip("the maximum angle the ground can have relative to the players up direction.")]
    public float maxSlopeAngle = 35f;
    private Vector3 crouchScale = new Vector3(1, 0.5f, 1);
    private Vector3 playerScale;
    [Tooltip("forward force for when a crouch is started.")]
    public float slideForce = 400;
    [Tooltip("countermovement when sliding. this doesnt work the same way as normal countermovement.")]
    public float slideCounterMovement = 0.2f;
    [Tooltip("Sets the max amount of jumps allowed (including initial jump from ground) before hitting the ground again")]
    public int jumpCacheMax = 2;
    public int jumpCache = 2;
    private bool jumpReset;
    private bool readyToJump = true;
    private float jumpCooldown = 0.25f;
    [Tooltip("this determines the jump force but is also applied when jumping off of walls, if you decrease it, you may end up being able to walljump and then get back onto the wall leading to infinite height.")]
    public float jumpForce = 550f; 
    float x, y;
    bool jumping;
    private Vector3 normalVector = Vector3.up;
/*
    [Header("Hovering")]
    Vector3 hoverVelocity;
    float hoverDuration = 2f;
    float fallDuration = 1f;
    bool hovering;
*/
    [Header("Gun Fancy Toggles")]
    bool swayEnabled;
    bool swayRotEnabled;
    bool bobEnabled;
    bool bobRotEnabled;
    
    [Header("Sway")]
    public float step = 0.01f; //Multiplied by the value from the mouse for 1 frame
    public float maxStepDistance = 0.06f; //Max distance from the local origin
    Vector3 swayPos; //Store our value for later
    float smooth = 10f; //Used by both BobOffset and Sway

    [Header("Sway Rotation")]
    public float rotationStep = 4f; //Multiplied by the value from the mouse 1 frame
    public float maxRotationStep = 5f; //Max rotation from the local identity rotation
    Vector3 swayEulerRot; //Store our value
    float smoothRot = 12f; //Used by both BobSway and TiltSway

    [Header("Bobbing")]
    public float speedCurve; //Used by both bobbing methods
    float curveSin { get => Mathf.Sin(speedCurve); } //Easy getter for the sin of our curve
    float curveCos { get => Mathf.Cos(speedCurve); } //Easy getter for the cos of our curve
    public Vector3 travelLimit = Vector3.one * 0.025f; //The maximum limits of travel from the move input
    public Vector3 bobLimit = Vector3.one * 0.01f; //Limits of travel from bobbing over time
    Vector3 bobPosition;

    [Header("Bob Rotation")]
    public Vector3 multiplier;
    Vector3 bobEulerRotation;

    [Header("WallRunning")]
    [Tooltip("Does Nothing because everything is built off the ground layer and changing it is a nightmare, so currently does nothing")]
    public LayerMask whatIsWall;
    private float actualWallRotation;
    private float wallRotationVel;
    private Vector3 wallNormalVector;
    [Tooltip("when wallrunning, an upwards force is constantly applied to negate gravity by about half (at default), increasing this value will lead to more upwards force and decreasing will lead to less upwards force.")]
    public float wallRunGravity = 1;
    [Tooltip("when a wallrun is started, an upwards force is applied, this describes that force.")]
    public float initialForce = 20f; 
    [Tooltip("float to choose how much force is applied outwards when ending a wallrun. this should always be greater than Jump Force")]
    public float escapeForce = 600f;
    private float wallRunRotation;
    [Tooltip("how much you want to rotate the camera sideways while wallrunning")]
    public float wallRunRotateAmount = 10f;
    [Tooltip("a bool to check if the player is wallrunning because thats kinda necessary.")]
    public bool isWallRunning;
    [Tooltip("a bool to determine whether or not to actually allow wallrunning.")]
    public bool useWallrunning = true;

    [Header("Hover Ability")]
    [Tooltip("Determines whether hovering is enabled")]
    public bool hoverAbility;
    public float hoverTimer = 1f;
    float gravityStrength = 0.9f;
    float decayRate = 0.5f;

    [Header("Collisions")]
    [Tooltip("is the player on the ground.")]
    public bool grounded;
    [Tooltip("is the player currently crouching.")]
    public bool crouching;
    private bool surfing;
    [Tooltip("is the player currently holding tab.")]
    private bool cancellingGrounded;
    private bool cancellingSurf;
    private bool cancellingWall;
    private bool cancelling;

    public static PlayerMovementImproved Instance { get; private set; }

    public bool movementStateLocked = false;

    void Awake() {
        Instance = this;

        rb = GetComponent<Rigidbody>();
        
        //Create a physic material with no friction to allow for wallrunning and smooth movement not being dependant
        //and smooth movement not being dependant on the in-built unity physics engine, apart from collisions.
        PhysicsMaterial mat = new PhysicsMaterial("tempNoFrictMat");

        mat.bounceCombine = PhysicsMaterialCombine.Average;

        mat.bounciness = 0;

        mat.frictionCombine = PhysicsMaterialCombine.Minimum;

        mat.staticFriction = 0;
        mat.dynamicFriction = 0;

        gameObject.GetComponent<Collider>().material = mat;
    }

    public Vector3 startingPosition;
    void Start() {
        startingPosition = transform.position;
        transform.position = startingPosition;
        playerScale = transform.localScale;
        Cursor.lockState = CursorLockMode.Locked;
        Cursor.visible = false;
        readyToJump = true;
        wallNormalVector = Vector3.up;
    }


    private void FixedUpdate() {
        Movement();
        //Hover();
        Sway();
        SwayRotation();
        BobOffset();
        BobRotation();
        GunSway();
    }

    private void Update() {
        if (!movementStateLocked){
            Busy();
            Upgrade();
            MyInput();
            Look();
        } else {
            NoInput();
        }
    }

    private void LateUpdate() {
        //call the wallrunning Function
        WallRunning();
        WallRunRotate();
    }

    private void Busy() {
        if (upgrading || buying) {
            busy = true;
            if (crouching) {
                StopCrouch();
            }
            Time.timeScale = 0.001f;
        } else {
            busy = false;
            Time.timeScale = 1;
        }
    }

    private void Upgrade() {
        if (escaped) {
            //Close Shop if open
            if (gameObject.GetComponent<InteractScript>().isShopOpen == true) {
                gameObject.GetComponent<InteractScript>().CloseShop();
            } else {
                if (canvas.GetComponent<PauseMenuScript>().menuDepth > 0) Debug.Log("Player is still in menu");
                if (!upgrading) {
                    upgrading = true;
                    canvas.GetComponent<PauseMenuScript>().ShowScreen();
                } else if (upgrading) {
                    if (canvas.GetComponent<PauseMenuScript>().menuDepth == 0) upgrading = false;
                    canvas.GetComponent<PauseMenuScript>().HideScreen();
                }
            }
        }
    }

    private void WallRunRotate() {
        FindWallRunRotation();
        float num = 12f;
        actualWallRotation = Mathf.SmoothDamp(actualWallRotation, wallRunRotation, ref wallRotationVel, num * Time.deltaTime);
        playerCam.localRotation = Quaternion.Euler(playerCam.rotation.eulerAngles.x, playerCam.rotation.eulerAngles.y, actualWallRotation);
    }

    /// Find user input. Should put this in its own class but im lazy
    private void MyInput() {
        x = Input.GetAxisRaw("Horizontal");
        y = Input.GetAxisRaw("Vertical");
        walkInput.x = x;
        walkInput.y = y;
        walkInput = walkInput.normalized;
        jumping = Input.GetButton("Jump");
        //upgrading = Input.GetKey(KeyCode.Tab);
        escaped = Input.GetKeyDown(KeyCode.Escape) || Input.GetKeyDown(KeyCode.Tab);
        aiming = Input.GetButton("Fire2");

        //Crouching
        if (!busy) {
            crouching = Input.GetButton("Crouch");
            if (Input.GetButtonDown("Crouch")) StartCrouch();
            if (Input.GetButtonUp("Crouch")) StopCrouch();
        }
    }

    private bool gameOver = false;
    private void NoInput() {
        if (gameOver) {
            Destroy(rb);
            Destroy(gameObject.GetComponent<PlayerMovementImproved>());
        }
        x = 0;
        y = 0;
        walkInput.x = x;
        walkInput.y = y;
        walkInput = walkInput.normalized;
        jumping = false;
        if (crouching) StopCrouch();
        crouching = false;
        //upgrading = Input.GetKey(KeyCode.Tab);
        escaped = false;
        aiming = false;
        rb.linearVelocity = Vector3.zero;
    }

    private void StartCrouch() {
        transform.localScale = crouchScale;
        transform.position = new Vector3(transform.position.x, transform.position.y - 0.1f, transform.position.z);
        if (rb.linearVelocity.magnitude > 0.2f && grounded) {
            if (grounded) {
                if (x > 0.5f || y > 0.5f) {
                    rb.AddForce(orientation.transform.forward * slideForce);
                }
            }
        }
    }

    private void StopCrouch() {
        crouching = false;
        transform.localScale = playerScale;
        transform.position = new Vector3(transform.position.x, transform.position.y + 0.1f, transform.position.z);
    }

    private void Movement() {
        //Extra gravity
        rb.AddForce(Vector3.down * Time.deltaTime * 10);

        //Find actual velocity relative to where player is looking
        Vector2 mag = FindVelRelativeToLook();
        float xMag = mag.x, yMag = mag.y;

        //Counteract sliding and sloppy movement
        CounterMovement(x, y, mag);

        if (!busy) {
            //If holding jump && ready to jump, then jump
            if (readyToJump && jumping) Jump();

            //Set max speed
            float maxSpeed = this.maxSpeed;

            if (grounded) ResetHover();

            //If sliding down a ramp, add force down so player stays grounded and also builds speed
            if (crouching && grounded && readyToJump && !jumping) {
                rb.AddForce(Vector3.down * Time.deltaTime * 3000);
                return;
            }

            //If speed is larger than maxspeed, cancel out the input so you don't go over max speed
            if (x > 0 && xMag > maxSpeed) x = 0;
            if (x < 0 && xMag < -maxSpeed) x = 0;
            if (y > 0 && yMag > maxSpeed) y = 0;
            if (y < 0 && yMag < -maxSpeed) y = 0;

            //Some multipliers
            float multiplier = 1f, multiplierV = 1f;

            // Movement in air
            if (!grounded) {
                multiplier = 0.5f;
                multiplierV = 0.5f;
            }
            if (grounded && crouching) {
                multiplierV = 0f;
            }
            if (isWallRunning) {
                multiplier = 0.3f;
                multiplierV = 0.3f;
            }
            if (surfing) {
                multiplier = 0.7f;
                multiplierV = 0.3f;
            }
            if (!jumpReset) {
                if (grounded && readyToJump || isWallRunning && readyToJump) {
                    jumpReset = true;
                    jumpCache = jumpCacheMax;
                }
            }

            //Apply forces to move player
            rb.AddForce(orientation.transform.forward * y * moveSpeed * Time.deltaTime * multiplier * multiplierV);
            rb.AddForce(orientation.transform.right * x * moveSpeed * Time.deltaTime * multiplier);
        }
    }

/*
    public void Hover() {
        if (!grounded && aiming) {
            hovering = true;
            Vector3 hoverVelocity = rb.velocity;
            hoverVelocity.x = 0;
            hoverVelocity.y = -hoverVelocity.y;
            hoverVelocity.z = 0;
            rb.AddForce(hoverVelocity);
        }
        hovering = false;
    }
*/

    private void Jump() {
        if (jumpCache <= 0) {
            return;
        } else if (jumpCache >= 1 ) {
            if (/*(grounded || isWallRunning || surfing) && */readyToJump) {
                //Debug.Log("jumping");
                readyToJump = false;
                jumpCache--;
                Vector3 velocity = rb.linearVelocity;
                rb.AddForce(Vector2.up * jumpForce * 1.5f);
                rb.AddForce(normalVector * jumpForce * 0.5f);
                if (rb.linearVelocity.y < 0.5f) {
                    rb.linearVelocity = new Vector3(velocity.x, 0f, velocity.z);
                }
                else if (rb.linearVelocity.y > 0f) {
                    rb.linearVelocity = new Vector3(velocity.x, velocity.y / 2f, velocity.z);
                }
                if (isWallRunning) {
                    rb.AddForce(wallNormalVector * jumpForce * 3f);
                }
                Invoke("ResetJump", jumpCooldown);
                if (isWallRunning) {
                    isWallRunning = false;
                }
            }
        }
    }

    private void ResetJump() {
        readyToJump = true;
        jumpReset = false;
    }

    private float desiredX;
    private void Look() {
        if (!busy) {
            //Toggle mouse usability
            Cursor.visible = false;
            Cursor.lockState = CursorLockMode.Locked;

            //Track mouse movements
            float mouseX = Input.GetAxisRaw("Mouse X") * sensitivity * Time.fixedDeltaTime * sensMultiplier;
            float mouseY = Input.GetAxisRaw("Mouse Y") * sensitivity * Time.fixedDeltaTime * sensMultiplier;

            lookInput.x = mouseX;
            lookInput.y = mouseY;

            //Find current look rotation
            Vector3 rot = playerCam.transform.localRotation.eulerAngles;
            desiredX = rot.y + mouseX;

            //Rotate, and also make sure we dont over- or under-rotate.
            xRotation -= mouseY;
            float clamp = 89.5f;
            xRotation = Mathf.Clamp(xRotation, -clamp, clamp);

            //Perform the rotations
            playerCam.transform.localRotation = Quaternion.Euler(xRotation, desiredX, 0);
            orientation.transform.localRotation = Quaternion.Euler(0, desiredX, 0);

            if (aiming == true) {
                swayEnabled = true;
                swayRotEnabled = false;
                bobEnabled = false;
                bobRotEnabled = false;
                gun.GetComponent<Gun>().ADSIn();
                if (hoverAbility) Hover();
            } else {
                swayEnabled = true;
                swayRotEnabled = true;
                bobEnabled = true;
                bobRotEnabled = true;
                gun.GetComponent<Gun>().ADSOut();
            }
        } else {
            //Toggle mouse usability
            Cursor.visible = true;
            Cursor.lockState = CursorLockMode.None;
            swayEnabled = false;
            swayRotEnabled = false;
            bobEnabled = false;
            bobRotEnabled = false;
            if (aiming == true) {
                gun.GetComponent<Gun>().ADSOut();
            }
        }
    }

    Vector3 gravity;
    void Hover() {
        if (hoverTimer <= 0) return;
        gravity = rb.linearVelocity;
        gravity.x = 0;
        gravity.z = 0;
        //Debug.Log(gravity.y);
        if (gravity.y <= 0) {
            gravity.y = -gravity.y * (gravityStrength * hoverTimer);
            //Debug.Log(gravity.y);
            //Extra gravity
            rb.AddForce(gravity * Time.deltaTime * 2250);
            hoverTimer -= Time.deltaTime * (Time.timeScale * decayRate);
        }
        canvas.GetComponent<PauseMenuScript>().UpdateHoverUI(hoverTimer);
    }

    void ResetHover() {
        hoverTimer = 1f;
        canvas.GetComponent<PauseMenuScript>().UpdateHoverUI(hoverTimer);
    }

    void Sway() {
        if (swayEnabled) {
            Vector3 invertLook = lookInput * -step;
            invertLook.x = Mathf.Clamp(invertLook.x, -maxStepDistance, maxStepDistance);
            invertLook.y = Mathf.Clamp(invertLook.y, -maxStepDistance, maxStepDistance);

            swayPos = invertLook;
        } else {
            swayPos = new Vector3(0,0,0);
        }
    }

    void SwayRotation() {
        if (swayRotEnabled) {
            Vector2 invertLook = lookInput * -rotationStep;
            invertLook.x = Mathf.Clamp(invertLook.x, -maxRotationStep, maxRotationStep);
            invertLook.y = Mathf.Clamp(invertLook.y, -maxRotationStep, maxRotationStep);

            swayEulerRot = new Vector3(invertLook.y, invertLook.x, invertLook.x);
        } else {
            swayEulerRot= new Vector3(0,0,0);
        }
    }

    void BobOffset() {
        if (bobEnabled) {
            //Used to generate our sin and cos waves
            speedCurve += Time.fixedDeltaTime * (grounded ? rb.linearVelocity.magnitude : 1f) + 0.01f;

            bobPosition.x = (curveCos * bobLimit.x * (grounded ? 1 : 0)) - (walkInput.x * travelLimit.x); //(bob) - (input offset)
            bobPosition.y = (curveSin * bobLimit.y) - (rb.linearVelocity.y * travelLimit.y); //(bob) - (y velocity offset)
            bobPosition.z = -(walkInput.y * travelLimit.z); // - (input offset)
        } else {
            bobPosition = new Vector3(0,0,0);
        }
    }

    void BobRotation() {
        if (bobRotEnabled) {
            bobEulerRotation.x = (walkInput != Vector2.zero ? multiplier.x * (Mathf.Sin(2 * speedCurve)) : multiplier.x * (Mathf.Sin(2 * speedCurve) / 2));
            bobEulerRotation.y = (walkInput != Vector2.zero ? multiplier.y * curveCos : 0);
            bobEulerRotation.z = (walkInput != Vector2.zero ? multiplier.z * curveCos * walkInput.x : 0);
        } else {
            bobEulerRotation = new Vector3(0,0,0);
        }
    }

    void GunSway() {
        //Debug.Log(gun.transform.parent.name);
        gun.transform.parent.GetComponent<GunSwayScript>().CompositePositionRotation(swayPos, smooth, bobPosition, swayEulerRot, bobEulerRotation, smoothRot);
    }

    private void CounterMovement(float x, float y, Vector2 mag) {
        if (!grounded || jumping) return;

        //Slow down sliding
        if (crouching)  {
            rb.AddForce(moveSpeed * Time.deltaTime * -rb.linearVelocity.normalized * slideCounterMovement);
            return;
        }

        //Counter movement
        if (Math.Abs(mag.x) > threshold && Math.Abs(x) < 0.05f || (mag.x < -threshold && x > 0) || (mag.x > threshold && x < 0)) {
            rb.AddForce(moveSpeed * orientation.transform.right * Time.deltaTime * -mag.x * counterMovement);
        }
        if (Math.Abs(mag.y) > threshold && Math.Abs(y) < 0.05f || (mag.y < -threshold && y > 0) || (mag.y > threshold && y < 0)) {
            rb.AddForce(moveSpeed * orientation.transform.forward * Time.deltaTime * -mag.y * counterMovement);
        }

        //Limit diagonal running. This will also cause a full stop if sliding fast and un-crouching, so not optimal.
        if (Mathf.Sqrt((Mathf.Pow(rb.linearVelocity.x, 2) + Mathf.Pow(rb.linearVelocity.z, 2))) > maxSpeed) {
            float fallspeed = rb.linearVelocity.y;
            Vector3 n = rb.linearVelocity.normalized * maxSpeed;
            rb.linearVelocity = new Vector3(n.x, fallspeed, n.z);
        }
    }

    /// Find the velocity relative to where the player is looking
    /// Useful for vectors calculations regarding movement and limiting movement
    public Vector2 FindVelRelativeToLook() {
        float lookAngle = orientation.transform.eulerAngles.y;
        float moveAngle = Mathf.Atan2(rb.linearVelocity.x, rb.linearVelocity.z) * Mathf.Rad2Deg;

        float u = Mathf.DeltaAngle(lookAngle, moveAngle);
        float v = 90 - u;

        float magnitude = rb.linearVelocity.magnitude;
        float yMag = magnitude * Mathf.Cos(u * Mathf.Deg2Rad);
        float xMag = magnitude * Mathf.Cos(v * Mathf.Deg2Rad);

        return new Vector2(xMag, yMag);
    }

    //a lot of math (dont touch)
    private void FindWallRunRotation() {
        if (!isWallRunning) {
            wallRunRotation = 0f;
            return;
        }
        _ = new Vector3(0f, playerCam.transform.rotation.y, 0f).normalized;
        new Vector3(0f, 0f, 1f);
        float num = 0f;
        float current = playerCam.transform.rotation.eulerAngles.y;
        if (Math.Abs(wallNormalVector.x - 1f) < 0.1f) {
            num = 90f;
        }
        else if (Math.Abs(wallNormalVector.x - -1f) < 0.1f) {
            num = 270f;
        }
        else if (Math.Abs(wallNormalVector.z - 1f) < 0.1f) {
            num = 0f;
        }
        else if (Math.Abs(wallNormalVector.z - -1f) < 0.1f) {
            num = 180f;
        }

        num = Vector3.SignedAngle(new Vector3(0f, 0f, 1f), wallNormalVector, Vector3.up);
        float num2 = Mathf.DeltaAngle(current, num);
        wallRunRotation = (0f - num2 / 90f) * wallRunRotateAmount;

        if (!useWallrunning) {
            return;
        }
        if ((Mathf.Abs(wallRunRotation) < 4f && y > 0f && Math.Abs(x) < 0.1f) || (Mathf.Abs(wallRunRotation) > 22f && y < 0f && Math.Abs(x) < 0.1f)) {
            if (!cancelling) {
                cancelling = true;
                CancelInvoke("CancelWallrun");
                Invoke("CancelWallrun", 0.2f);
            }
        }
        else {
            cancelling = false;
            CancelInvoke("CancelWallrun");
        }
    }

    private bool IsFloor(Vector3 v) {
        return Vector3.Angle(Vector3.up, v) < maxSlopeAngle;
    }

    private bool IsSurf(Vector3 v) {
        float num = Vector3.Angle(Vector3.up, v);
        if (num < 89f) {
            return num > maxSlopeAngle;
        }
        return false;
    }

    private bool IsWall(Vector3 v) {
        return Math.Abs(90f - Vector3.Angle(Vector3.up, v)) < 0.05f;
    }

    private bool IsRoof(Vector3 v) {
        return v.y == -1f;
    }

    /// Handle ground detection
    private void OnCollisionStay(Collision other) {
        int layer = other.gameObject.layer;
        if ((int)whatIsGround != ((int)whatIsGround | (1 << layer))) {
            return;
        }
        for (int i = 0; i < other.contactCount; i++) {
            Vector3 normal = other.contacts[i].normal;
            if (IsFloor(normal)) {
                if (isWallRunning) {
                    isWallRunning = false;
                }
                grounded = true;
                normalVector = normal;
                cancellingGrounded = false;
                CancelInvoke("StopGrounded");
            }
            if (IsWall(normal) && (layer == (int)whatIsGround || (int)whatIsGround == -1 || layer == LayerMask.NameToLayer("Ground") || layer == LayerMask.NameToLayer("Ground"))) { //Seriously, what is this
                StartWallRun(normal);
                cancellingWall = false;
                CancelInvoke("StopWall");
            }
            if (IsSurf(normal)) {
                surfing = true;
                cancellingSurf = false;
                CancelInvoke("StopSurf");
            }
            IsRoof(normal);
        }
        float num = 3f;
        if (!cancellingGrounded) {
            cancellingGrounded = true;
            Invoke("StopGrounded", Time.deltaTime * num);
        }
        if (!cancellingWall) {
            cancellingWall = true;
            Invoke("StopWall", Time.deltaTime * num);
        }
        if (!cancellingSurf) {
            cancellingSurf = true;
            Invoke("StopSurf", Time.deltaTime * num);
        }
    }

    private void StopGrounded() {
        grounded = false;
    }

    private void StopWall() {
        isWallRunning = false;
    }

    private void StopSurf() {
        surfing = false;
    }

    //wallrunning functions
    private void CancelWallrun() {
        //for when we want to stop wallrunning
        //Debug.Log("cancelled wallrun");
        //Invoke("GetReadyToWallrun", 0.01f);
        rb.AddForce(wallNormalVector * escapeForce);
        isWallRunning = false;
    }

    private void StartWallRun(Vector3 normal) {
        //Debug.Log("wallrunning");
        //Cancels all y momentum and then applies an upwards force.
        if (!grounded && useWallrunning) {
            wallNormalVector = normal;
            if (!isWallRunning) {
                rb.linearVelocity = new Vector3(rb.linearVelocity.x, 0f, rb.linearVelocity.z);
                rb.AddForce(Vector3.up * initialForce, ForceMode.Impulse);
            }
            isWallRunning = true;
        }
    }

    private void WallRunning() {
        //checks if the wallrunning bool is set to true and if it is then applies
        //a force to counter gravity enough to make it feel like wallrunning
        if (isWallRunning) {
            rb.AddForce(-wallNormalVector * Time.deltaTime * moveSpeed);
            rb.AddForce(Vector3.up * Time.deltaTime * rb.mass * wallRunGravity * -Physics.gravity.y * 0.4f);
        }
    }

}

💡 What Makes This Special

Feature-rich but cohesive

I packed several common FPS movement features into one unified component—walking, crouch/slide, jump caching, wallrunning, surfing detection, aiming state, hover, and procedural gun sway/bob. Rather than scattering logic across many scripts, I kept movement, camera look, and weapon-sway orchestration in one place so the interplay between movement and camera/gun systems is explicit and easy to reason about.

Physics-driven, responsive movement

I intentionally used Rigidbody forces and velocity control (instead of purely kinematic transforms) so movement interacts naturally with Unity’s physics — collisions, slopes, wall normals, and momentum feel organic. The code applies additive forces scaled by Time.deltaTime, uses counter-movement to tame unwanted momentum, and clamps diagonal speed — all of which produce controlled, responsive movement while preserving physics realism.

Key Takeaways

This project taught me crucial lessons about game design and technical planning. Initially, we used Unity's NavMesh for muskrat pathfinding, but it couldn't handle dynamic obstacles (players placing boxes in the way). I had to research and implement custom A* pathfinding with real-time pathfinding updates, which was a significant technical challenge.

I learned the importance of clear data structures when I had to refactor the enemy managers three times. The enemies have 3 levels of management controlling their AI. The top one controls where muskrats spawn, what type of enemy it is and where its destination is. The second level is in charge of maintaining those muskrats and keeping them on track in case an obstacle blocks its path. The third is responsible of keeping track of enemy health and what should happen to them when they get captured.

Working under a tight deadline taught me to scope features realistically. We had ambitious plans for environmental effects and multiple enemy types, but I had to prioritize core mechanics and polish over feature bloat. The finished game has fewer features than initially planned but everything that made it in works flawlessly.

Technical Highlights

Technical implementations in Dike Keepers:

**Custom A* Pathfinding**: Dynamic grid-based pathfinding that recalculates enemy paths when players obstructs hallways.

**Resource Management**: Rewarding economy with observers for UI updates. Includes income calculations, repair costs, and upgrade pricing with difficulty scaling, to keep the game exciting deep into the game.

**Complex Multi-Layered AI**: Three brains controlling and managing a horde of muskrats gives this game an intense and fast-moving edge that the player needs to think quick for to catch up to.

Interested in This Project?

I'd love to discuss this project in more detail or explore how similar solutions could benefit your team. Let's connect!