Featherfall is a hack 'n slash, action adventure game where you take the role of a spear fisher blessed by the sea, while you make your way through a rich and detailed world inspired by inuit mythology.

Make use of the interactive combat system and fight your way through hordes of creatures, explore the barren landscape to find the path forward and open the way to the edge of the world.




Hack 'n slash


Unity 3D

Team Size



7 Weeks



using UnityEngine;
using UnityEngine.Assertions;

public class MovementController : MonoBehaviour {
	[Header("Movement")] [SerializeField] float walkSpeed = 10f;
	[SerializeField] float turnSmoothTime = 0.2f;
	[SerializeField] float speedSmoothTime = 0.1f;
	[SerializeField] float attackStepLength = 3f;
	[SerializeField] float attackTurnTime = 0.2f;
	float turnSmoothVelocity;
	float speedSmoothVelocity;
	float currentSpeed;
	Vector2 input;

	[Header("Collision")] [SerializeField] float gravity = 100f;
	[SerializeField] LayerMask groundLayer;
	[SerializeField] float groundCheckDistance = 0.55f;

	bool isGrounded = true;
	Camera camera;
	Vector3 velocity;
	Transform cameraTransform;
	Vector3 groundCheckPosition;
	CharacterController controller;
	Animator animator;
    CapsuleCollider collider;
    private float speedSmooth;
	private static readonly int RunSpeeder = Animator.StringToHash("RunSpeeder");
	private static readonly int Running = Animator.StringToHash("Running");
    private static readonly int RunSpeed = Animator.StringToHash("RunSpeed");

    float tickUp = 0;
    bool shouldTick = false;
    float turnTime;

    private void Start() {
		controller = GetComponent< CharacterController>();
		animator = GetComponent< Animator>();
		camera = Camera.main;
		cameraTransform = camera.transform;
		Cursor.lockState = CursorLockMode.Locked;
		Cursor.visible = true;
		collider = GetComponent< CapsuleCollider>();

        turnTime = turnSmoothTime;
        //Assertions (These will disappear in the final build.)
        Assert.IsNotNull(controller, "You need to add a Character Controller to player!");
		Assert.IsNotNull(collider, "You need a Capsule Collider on the character!");

	private void Update() {
		if (!animator)
			animator = GetComponent< Animator>();
		if (!controller)
			controller = GetComponent< CharacterController>();

		animator.SetBool(RunSpeeder, currentSpeed >= 0.1f);

        groundCheckPosition = (transform.TransformPoint(collider.center) - Vector3.up * (collider.height * 0.5f - collider.radius));
        isGrounded = Physics.CheckSphere(groundCheckPosition, groundCheckDistance, groundLayer,
		if (isGrounded && velocity.y < 0)
			velocity.y = 0;

		input = PlayerInput.movementInput;
		Vector2 inputDirection = input.normalized;
		float inputMagnitude = input.magnitude;
		inputMagnitude = Mathf.Clamp(inputMagnitude, 0f, 1f);

        if(!MeleeAttack_C.canPlayerMove) // Attacking
            tickUp = 0f;
        else // Not Attacking
            tickUp += Time.deltaTime;
            //if(turnTime < turnSmoothTime)
            turnTime = Mathf.Lerp(0, turnSmoothTime, tickUp * turnSmoothTime);

		if (inputDirection != Vector2.zero && MeleeAttack_C.canPlayerMove) // Not Attacking
				float targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.y) * Mathf.Rad2Deg +
				transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle(transform.eulerAngles.y, targetRotation,
					                        ref turnSmoothVelocity, turnTime);
        else if(inputDirection != Vector2.zero) // Attacking
            turnTime = attackTurnTime;
            float targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.y) * Mathf.Rad2Deg +
            transform.eulerAngles = Vector3.up * Mathf.SmoothDampAngle(transform.eulerAngles.y, targetRotation,
                                        ref turnSmoothVelocity, turnTime);
		float targetSpeed =  walkSpeed * inputDirection.magnitude;
		if (isGrounded)
            animator.SetFloat(RunSpeed, inputMagnitude);

        currentSpeed = Mathf.SmoothDamp(currentSpeed, targetSpeed, ref speedSmoothVelocity, speedSmoothTime);
		Vector3 finalMove = currentSpeed * transform.forward;
		if (!Blink.isDashing)
			velocity.y -= gravity * Time.deltaTime;
		if (MeleeAttack_C.canPlayerMove)
            controller.Move(velocity + Time.deltaTime * inputMagnitude * finalMove);
        else if (!MeleeAttack_C.canPlayerMove && targetSpeed != 0)
            controller.Move(Time.deltaTime * attackStepLength  * transform.forward);

    private void OnDrawGizmos()
        Gizmos.DrawWireSphere(groundCheckPosition, groundCheckDistance);

using UnityEngine;

public class CameraController : MonoBehaviour {
	[Header("Camera Movement")]
	[SerializeField] float distanceFromTarget = 2f;
	[SerializeField] float distanceFromTargetInCombat = 2f;
	[SerializeField] float rotationSmoothTime = 0.12f;
	[SerializeField] float maxDistanceFromTarget = 30f;
	[Range(0.5f, 5f)] [SerializeField] float cameraDashDelay = 2f;
    [Range(0f, 5f)] [SerializeField] float cameraZoomDelay = 2f;
	[SerializeField] Transform target;
	[Header("Collision")][Tooltip("The layer that the camera should collide with")][SerializeField] LayerMask groundLayer;

	Vector3 cameraCollisionOffset;
	[Range(0f, 1f)] [SerializeField] float cameraCollisionOffsetX;
	[Range(0f, 1f)] [SerializeField] float cameraCollisionOffsetY = 0.5f;
	[Range(0f, 1f)] [SerializeField] float cameraCollisionOffsetZ;
	[SerializeField] Vector3 cameraLineTraceOffset;

	Vector3 rotationSmoothVelocity;
	Vector3 currentRotation;
	Vector3 startPosition;
	Vector3 targetPosition;
	float initialDuration;
	float originalDist;
	AISystem aiSystem;

	private void Start()
		originalDist = distanceFromTarget;
		cameraCollisionOffset = new Vector3(cameraCollisionOffsetX, cameraCollisionOffsetY, cameraCollisionOffsetZ);
		aiSystem = GameManager.GM.aiSystem;

	private void LateUpdate()
		if (aiSystem.isInCombat)
			distanceFromTarget = Mathf.Lerp(distanceFromTarget, distanceFromTargetInCombat,cameraZoomDelay * Time.deltaTime);
		else if (!aiSystem.isInCombat)
			distanceFromTarget = Mathf.Lerp(distanceFromTarget, originalDist,cameraZoomDelay * Time.deltaTime);
		currentRotation = Vector3.SmoothDamp(currentRotation,
		new Vector3(PlayerInput.yawPitch.x, PlayerInput.yawPitch.y), ref rotationSmoothVelocity,

        transform.eulerAngles = currentRotation;
        targetPosition = target.position - transform.forward * distanceFromTarget;

		CompensateForWalls(target.position, ref targetPosition);
		transform.position = targetPosition;

		if (Blink.isDashing) {
			float dur = 0;
			dur += cameraDashDelay * Time.deltaTime;
			distanceFromTarget = Mathf.Lerp(distanceFromTarget, maxDistanceFromTarget, dur);
		else if (!Blink.isDashing) {
			float dur = 0;
			dur += cameraDashDelay * Time.deltaTime;
			distanceFromTarget = Mathf.Lerp(distanceFromTarget, aiSystem.isInCombat? distanceFromTargetInCombat:originalDist, dur);

	private void CompensateForWalls(Vector3 fromObject, ref Vector3 toTarget) {
		if (Physics.Linecast(fromObject, toTarget + cameraLineTraceOffset, out RaycastHit hit, groundLayer)) {
			toTarget = new Vector3(hit.point.x, hit.point.y, hit.point.z) + cameraCollisionOffset;
using System.Collections;
using UnityEngine;
using UnityEngine.AI;

public class Blink : MonoBehaviour

    [Tooltip("How fast do you want the dash to be.")][Range(2f, 100f)][SerializeField] float blinkSpeed = 20f;
    [Tooltip("How long should the blink last.")][Range(0.1f, 1f)][SerializeField] float blinkDuration = 0.5f;
    [SerializeField] private GameObject vfx;
    [SerializeField] private NavMeshObstacle obstacle;
    public static bool isDashing;

    Animator animator;
    CharacterController controller;
    LayerMask layerToIgnore;
    LayerMask playerLayer;
    AudioManager audioManager;
    float duration;

    private static readonly int IsShadowDashing = Animator.StringToHash("IsShadowDashing");

    private static readonly int Running = Animator.StringToHash("Running");

    private void Start() {
        animator = GetComponent< Animator>();
        controller = GetComponent< CharacterController>();
        audioManager = FindObjectOfType< AudioManager>();
        playerLayer = LayerMask.NameToLayer("Player");
        layerToIgnore = LayerMask.NameToLayer("Enemy");

    public IEnumerator Dash() {
        if (audioManager)
            Debug.Log("the blink script on " + gameObject.name + " has noticed that there is no audiomanager in the scene");

        animator.SetBool(Running, false);
        animator.SetBool(IsShadowDashing, true);
        isDashing = true;
        obstacle.enabled = false;
        Physics.IgnoreLayerCollision(playerLayer, layerToIgnore);
        float startTime = Time.time + blinkDuration;
        while (startTime > Time.time ) {
            vfx.transform.position = transform.position;
            controller.Move(blinkSpeed * Time.deltaTime * transform.forward);
            yield return new WaitForEndOfFrame();
        Physics.IgnoreLayerCollision(playerLayer, layerToIgnore, false);
        isDashing = false;
        animator.SetBool(IsShadowDashing, false);
        obstacle.enabled = true;

using UnityEngine;
using UnityEngine.Experimental.VFX;

public class TotemDestruction : MonoBehaviour {

	[SerializeField] VisualEffect notCleansed, cleansed;
	[SerializeField] Texture newTotemTexture;

	[SerializeField] private GameObject leftBranch, rightBranch, leftFeathers, rightFeathers, 
		topSinew, bottomSinew;
	private static readonly int EmissiveColor = Shader.PropertyToID("_EmissiveColor");
	private static readonly int EmissiveTexture = Shader.PropertyToID("_EmissiveTexture");
	private static readonly int DissolveAmount = Shader.PropertyToID("_DissolveAmount");
	private static readonly int CleansedColor = Shader.PropertyToID("_CleansedColor");
	private Renderer leftBranchRenderer, rightBranchRenderer, bottomSinewRenderer, topSinewRenderer, totemRenderer;
	private float leftDisolvingTime, rightDisolvingTime, middleDisolvingTime;
	private bool leftBranchHit, rightBranchHit, sinewsHit;
	private Color newColor;

	private void Start() {
		leftBranchRenderer = leftBranch.GetComponent< Renderer>();
		rightBranchRenderer = rightBranch.GetComponent< Renderer>();
		topSinewRenderer = topSinew.GetComponent< Renderer>();
		bottomSinewRenderer = bottomSinew.GetComponent< Renderer>();
		totemRenderer = GetComponent< Renderer>();
		newColor = totemRenderer.material.GetColor(CleansedColor);
	private void Update() {
		if (leftBranchHit) {
			StartDisolving(ref leftDisolvingTime, leftBranchRenderer);
			if (leftDisolvingTime == 1) {
				leftBranchHit = false;
		if (rightBranchHit) {
			StartDisolving(ref rightDisolvingTime, rightBranchRenderer);
			if (rightDisolvingTime == 1) {
				rightBranchHit = false;	
		if (!sinewsHit) return;
		StartDisolving(ref middleDisolvingTime, bottomSinewRenderer);
		StartDisolving(ref middleDisolvingTime, topSinewRenderer);
		if (middleDisolvingTime != 1) return;
		sinewsHit = false;

	private void StartDisolving(ref float disolvingTime, Renderer rendererToDisolve) {
		disolvingTime = Mathf.Clamp(disolvingTime + Time.deltaTime * 0.5f, 0f, 1f);
		rendererToDisolve.material.SetFloat(DissolveAmount, disolvingTime);

	public void DisolveParts(int health) {
		switch (health) {
		case 0:
			sinewsHit = true;
		case 1:
			rightBranchHit = true;
		case 2:
			leftBranchHit = true;

	private void ChangeTexture() {
		totemRenderer.material.SetColor(EmissiveColor, newColor);
		totemRenderer.material.SetTexture(EmissiveTexture, newTotemTexture);

using UnityEngine;
using UnityEngine.Experimental.VFX;

public class TotemHealth : MonoBehaviour {
    [SerializeField] int maxHealth = 100;
    [SerializeField] GameObject damage_VFX;

    [SerializeField] AudioSource purifyAS;
    [SerializeField] AudioSource hitAS;
    [SerializeField] AudioClip[] hits_SFX;

    [HideInInspector] public DoorHandler door;
	private TotemDestruction totemDestruction;
	private int health;
	[SerializeField] VisualEffect vfx;
	private bool shouldDoOnce = true;
	private float beaconTimer;
	private bool isBeaconLit;

	void Start() {
		door = FindObjectOfType< DoorHandler>();
		health = maxHealth;
		totemDestruction = GetComponent< TotemDestruction>();

	private void Update() {
        if (!isBeaconLit) return;
        if (!IsCurrentTotemClensed()) return;
        isBeaconLit = false;

	public void Damage(int damageDealt) {
        if (health <= 0)

        hitAS.clip = hits_SFX[UnityEngine.Random.Range(0, hits_SFX.Length)];

        if (health == 1)

        damage_VFX.GetComponent< VisualEffect>().SendEvent("Play");

        health -= damageDealt;
		if (health > 0 || !shouldDoOnce) return;
		shouldDoOnce = false;

	public bool IsCurrentTotemClensed() {
		return health <= 0;

	public void LightTheBeacons() {
		isBeaconLit = true;


Character Abilities:

    • Movement and camera
    • Dash
    • Spin attack
    • Execute


    • Input system


    • Main menu implementation
    • Totem Cleansing
    • Source control assistance (Perforce & Unity Collab)

Character Abilities

Movement and camera

Implemented the movement and camera controls for the main character so that the player moves relative to where the camera is pointing. Also added gravity to the player and camera collision so that the camera don't clip into walls.


Implemented a dash that goes through enemies where the design team could decide on the lenght and speed of the dash.

Spin attack

Implemented a spin attack that damaged everything in a radius around the player. At first it was supposed to use weapon collision to deal the damage but with time becoming an issue with not having animations ready it got changed to have a set radius, that could be changed from the editor, that hits the enemies.


Implemented an ability that after a certain amount of stacks, that are generated with each hit from attacks or other abilities, could be activated and spawned in spears under the enemies and kills them. 


Input system

Around the half way point of the project we noticed that changing the inputs for the abilities was difficult since you had to change them in different scripts in the project. So I created an input system to handle all the inputs and have them collected within one script.


Main menu

Implemented the main menu so that the options menu works together with the pause menu.


Added a hit counter to the totems and every time they get hit they lose a part of the decorations. When the counter is empty the beacon turns off and the totems counterpart on the podium lights up.