top of page
Unity Icon

PROJECT A.N.U.B.I.S.

June 2023 | Independent Project

First person shooter with an emphasis on movement where you can swap bodies with your enemies to gain different weapons, and abilities. Aesthetics inspired by ancient Egypt and cyberpunk media.

​

I was the main programmer as well as co-game designer on this project.

Project A.N.U.B.I.S. Screenshot

Background

This project was developed in a month, mainly by me and one other collaborator, though some people would come in and contribute to the project every once in a while. This was my first time working as a programmer for an ambitious project, and it turned out quite well.

GAME DESIGN

With this game we wanted to create a fast-paced shooter where players had to constantly think on their feet and improvise to survive. To accomplish this, we wanted players to have many movement options, allowing them to make quick decisions and take full advantage of their environments to get the upper hand. Similarly, we recognized that players should have low health to always keep them moving. These design pillars eventually led us to create the central mechanic of the game.

​

Body Hacking

We came up with this concept of switching bodies with enemies, inspired by games like Mario Odyssey in combination with the core idea of hero shooters like Overwatch where different characters have different guns, abilities, and movement options. By using this ability, you will take over the selected enemy's body. This means you will now be using their kit, as well as adopting their stats such as health, ammo, movement speed, etc. 

Body switching fits into our design pillars in multiple ways. For one, it allows us to give players less health due to the fact that healing now occurs when switching to a different body. This in turn makes players switch bodies much more often, meaning they are continuously switching loadouts, potentially to ones that they might be less conformable with, forcing them to improvise constantly.

​

Additionally, this system creates an interesting dynamic when creating levels. Instead of placing health, ammo, and weapon pickups, the enemies fill up that role on their own. This creates a sort of intrinsic balance, since placing down a powerful enemy also means players have access to a powerful weapon.

​

Movement Options

Since the game can get quite chaotic, we felt it was important to let the player move around a lot. Classes come with the following moves by default:

  • Double Jump

  • Hacking

  • Dash

  • Slide

  • Ground Pound​

Sliding was added as a way to add more speed to the player when moving around. It has a higher base speed than walking, and can carry any additional momentum you had prior to activation. You can indefinitely slide, but can't change direction while doing it. This means players must commit to a certain direction when sliding, however, ​they are also free to aim wherever they want, allowing players to have constant movement while completely focusing on combat.

Since sliding can only be done while grounded we wanted players to have a quick way to get back to the ground if necessary, this is where ground pounding came from. This move will send the player downward at an incredible speed until they hit the ground, and since it is activated using the same key as sliding, it will then transition smoothly into a slide.

As an additional use for this mechanic, if the player lets go of the slide key while ground pounding, they will bounce higher than the height where the ground pound was initiated. Additionally, on impact they will create a shockwave, dealing damage to nearby enemies and sending objects flying. This mechanic allows us to add much more verticality to levels since players now have a way to both gain and lose height very quickly.

Emergent Gameplay

It was very important to us that all of the systems at play could interact with each other, and that they reacted in expected but exciting ways. Let's look at the grenade ability as an example. 

On its own it's quite simple, you throw it, it blows up on impact, deals damage, and pushes physics objects away. However, looking at the rules I just outlined we can imagine multiple different ways this could interact with the rest if the game. For example, since the player is a physics object themselves we get rocket jumping, this is of course applicable to all other explosives in the game.

Taking this further, I said that it blows up on impact, but this can be anything not just walls. This means that you can shoot the grenade strategically to blow it up. This could be used to catch more enemies within the explosion radius and deal more damage. Adding to the rocket jumping example, if you have no walls around you, simply shoot the grenade yourself to gain momentum in any direction!

Taking it even further, movement abilities such as dashing or sliding can carry momentum the player has accumulated, so we can expand on this example even more by sliding immediately after rocket jumping which results in a massive speed gain. This is rarely useful, but it goes to show just how literal all the rules of the game are, and how interesting the intersection of those rules can be.

Results

Due to time constraints we were unable to continue working on this project, but I was extremely satisfied with the work we did. The game we made encapsulated our design pillars perfectly, the movement was incredibly expressive and fun, combat was fast paced and pushed players to use the hacking mechanic often, different classes felt unique but balanced. My only regret with this project is not being able to finish it. 

PROGRAMMING

As the main gameplay programmer for this project there were many considerations that needed to be made when first starting this project. As outlined in the game design section, the game needed to accommodate different player classes with different loadouts, including different weapon types, abilities, melee attacks, and stats; this meant that the system needed to be extremely flexible to account for any future additions. At the same time, systems needed to interact with each other seamlessly to create emergent gameplay, meaning logic needed to be as open ended and ambiguous as possible to account all possibilities. Additionally, everything needed to be extremely modular to fine tune the game feel while playing.

​

Locomotion

Initially, I thought using a 'Rigidbody' would be ideal for the character controller because we wanted momentum to be a prevalent idea in the game, however after some experimentation I opted to use a 'Chararacter Controller' instead. This helped me have much more control over how the physics would behave, but it required much more work. By the end, I had a wide variety settings and values to tweak which made the character controller extremely customizable.​​

Movement script in inspector

With this many options available I, of course, made sure to categorize them ​​with headers, provided tooltips for all of them, and made sure they were validated to avoid invalid values to make it as easy as possible for designers to come in and do their job. The actual locomotion part of the script is quite simple, it is a standard first-person character controller.​

    //Called in fixed update
    private void ApplyMovement()
    {
        #region Movement Exceptions

        //Basic movement
        _moveDirection = transform.right * PlayerInput.x + transform.forward * playerInput.y;
        _moveDirection.y = _yVelocity;


        #region Explostion Forces

        _characterController.Move(_moveDirection * MovementSpeed * Time.deltaTime);
    }


    //Called in fixed update
    public void ApplyGravity()
    {
        if(_isDashing || _forceReceiver.receivingExplosion) return;
​
        //Apply gravity when not grounded
        if(!_characterController.isGrounded)
            _yVelocity += Gravity * GravityDirection * Time.deltaTime;
​
        _moveDirection.y = _yVelocity;
    }

Dashing & Sliding

​These work similarly to basic movement, but without updating the 'moveDirection', meaning that it adopts the direction of the frame prior to initiating the slide. Sliding should also stop when hitting a wall head on; to implement this functionality I check the velocity of the 'Character Controller' directly, and if it goes below a certain threshold it'll cancel the slide. This makes it so that you can still grace against walls while sliding without issues, but hitting it directly cancels it. Then it returns.​​

    //Called in fixed update
    private void ApplyMovement()
    {
        //Dash Movement
        if(_isDashing)
        {
            _characterController.Move(_moveDirection * DashSpeed * Time.deltaTime);
            return;
        }
​
        //Slide Movement
        else if(_isSliding)
        {
            //Get sliding velocity
            Vector3 slidingVelocity = _moveDirection * SlideSpeed;
            slidingVelocity.y = _yVelocity * MovementSpeed;
            _characterController.Move(slidingVelocity * Time.deltaTime);
​
            //Check if hit wall
            Vector2 slideHorizontalSpeed =  new Vector2(_characterController.velocity.x,
            _characterController.velocity.z);
            if(slideHorizontalSpeed.magnitude < SlideLeeway) CancelSlide();
            return;
        }
       
        #region
Basic Movement
        #region Explosion Forces
    }

Ground Pounding

Ground pounding works by directly setting the 'yVelocity' value which is used in the 'ApplyMovement' function. It records the player's position on activation. Once the player hits the ground, it records the ending position and calculates the vertical distance the player traveled. The total distance is multiplied by a certain value, and then clamped within certain limits. This creates the desired effect of bouncing higher based on distance traveled while keeping it constrained.

    //Activated with input
    public void GroundPound()
    {
        if(_currentStamina == 0 || _isGroundPounding || _characterController.isGrounded || !_canAct)
            return;
 
        _isGroundPounding = true;
        CancelAllActions();
        _currentStamina --;

        _groundPoundStartPosition = transform.position;
        _yVelocity = GroundPoundStrength;
 
        _staminaRecoveryCoroutines.Add(StartCoroutine("IStaminaCooldown"));
    }
 
    //Called when landing while ground pounding
    public void GroundPoundBounce()
    {
        //Calculate bounce strength
        _groundPoundEndPosition = transform.position;
        float distanceFallen = _groundPoundStartPosition.y - _groundPoundEndPosition.y;
        float groundPoundBounceStrength = distanceFallen * GroundPoundBounceFactor;
        groundPoundBounceStrength = Mathf.Clamp(groundPoundBounceStrength, GroundPoundBounceLimit.x,    
        GroundPoundBounceLimit.y);

        //Do ground pound
        _yVelocity = groundPoundBounceStrength;
        GroundPoundDamage();
        _playerAudio.PlayGroundPoundImpact();
    }

Adding Forces

Since I am not using a 'Rigidbody' for the character controller I had to come up for a different solution. Forces work by leveraging animation curves. Parameters are set up to decide base force, maximum force, force duration, etc. These are extremely customizable both in a big picture sense where you can change how the player will react to forces, as well as on a case by case basis, for example here is how the rocket launcher explosion is set up.

Force Receiver script that goes on the player
Sample explotion taken from the rocket launcher

The 'Explosion' script calls the AddForce method as easily as you would call the equivalent method for a 'Rigidbody'. Very importantly, the force is additive, and doesn't override all movement or other forces.

    public void AddForce(Vector3 direction, float force, bool keepYPositive)
    {
        //Cancel all motion
        _movementScript.CancelAllActions();
        _movementScript.yVelocity = 0;
       
        //Reset animation curves
        _verticalTime = 0;
        _horizontalTime = 0;

        direction.Normalize();        

        #region Y direction exceptions
 
        //Calculate force
        _impact += direction * force / Mass;
        ClampForce(_impact);

        _receivingExplosion = true;
    }

Then the explosion force is processed by reading the animation curve like so:

    private void Update()
    {
        if(!_receivingExplosion) return;

        //Change bool which allows movement to account for forces
        if (_impact.magnitude < MinForce)
        {
            _receivingExplosion = false;
            return;
        }

        // Adds drag to the explosion by reading the animation curve
        _impact.x = _impact.x * HorizontalCurve.Evaluate(_horizontalTime) * ExplosionForceDuration;
        _impact.z = _impact.z * HorizontalCurve.Evaluate(_horizontalTime) * ExplosionForceDuration;
        _horizontalTime += Time.deltaTime;

        //Handle Y velocity as usual
        if(!_yNegative)
        {
            _impact.y =  _impact.y * VerticalCurve.Evaluate(_verticalTime) * ExplosionForceDuration;
            _verticalTime += Time.deltaTime;
        }
        
        #region Y velocity exceptions
    }

And finally, the force is applied to the player. The sequencing here is crucial to creating momentum. Since this occurs directly after the basic movement is calculated it can be truly additive to that motion. At the same time, forces are calculated after special movement options such as dashing and sliding, which ignore any instructions that follow, this means they carry the exact amount of force the player had prior to activation for the entirety of the move.

    //Called in fixed update
    private void ApplyMovement()
    {
        #region Movement Exceptions
        #region Basic Movement
 
        //Adds explosion force if necessary
        if(_forceReceiver.receivedExplosion)
            _moveDirection += _forceReceiver.impact;

        _characterController.Move(_moveDirection * MovementSpeed * Time.deltaTime);
    }

Loadouts

Loadouts are at the core of the game. Enemies will have different loadouts which the player will temporarily adopt when taking over their bodies. A loadout is defined by 3 components:

  • Gun

  • Melee

  • Secondary Ability

​

Guns & Melee Pipeline

Different weapons can be set up using scriptable objects with a wide variety of settings. All guns including assault riffles, shotguns, and rocket launchers, which have wildly different behaviors can be set up using the same scriptable object base. Similarly, melee attacks have their own class which allows different behaviors. 

Gun scriptable object example
Melee scriptable object example

This means that the creation of new weapons was a simple matter of creating a new instance of this scriptable object and filling out the values. The system was created with common weapon types in mind, but it's flexible enough to create entirely unique weapons. Take a look at the staff weapon as an example:

Bullet Pattern Visualizer

As I was developing the shotgun I realized it was too much of a hassle to create a bullet pattern just by adjusting vector 2 values. Additionally, it was hard for me to adjust individual bullets because it was hard to tell which bullet was which by looking at a huge list of values. This led me to creating this tool which visualized exactly what the bullet pattern would look like. This saved me tons of time while designing for this game, but the tool is flexible enough that I've been able to use it on other projects with only a few tweaks.

image.png
image.png

Downsides

Though the user experience on these tools is fantastic, I'll be the first to admit that I did not handle the backend side of things as gracefully. The 'Gun' script, for example, is full of if statements and exceptions for functionality that would have been better handled by inheritance and subclasses. I bring this up for a reason, I believe that recognizing mistakes is crucial for growth, looking back at this code and recognizing that there are way better ways I could've handled it also highlights just how much I've improved in this particular field since working on this project. If you would like examples of me creating similar systems in more effective ways feel free to look at the Meme Fighters and Recreating Neon White projects.

​

Secondary Abilities

Since abilities are so distinct from each other, I opted to create different scripts for each one. These can vary from throwing different grenade types, to laser beams, and grapple hooks. Even though they are so different, I could have still benefited from inheritance and event listeners while implementing these, similar to how I implemented items in Nautilus. Even then, all the abilities worked perfectly, and the fact that they all work independently means that adding new abilities doesn't require any interaction with the rest of the system.

​

Additionally, there were some abilities I would like to highlight. For example, the ability "barrage" which throws a grid of sticky grenades. I was told that the ability should throw a 3x3 grid of evenly spaced projectiles, but I wanted to account for any changes in the design, or additional uses for this script, so I created an algorithm which can create a grid of any size.

    //Creates a grid of any size
    for(int i = 0; i < totalGridSize; i ++)
    {
        explosivePostion[i] = new Vector2(currentPosition.x + ExplosiveSpacing, currentPosition.y);
        gridPosition ++;
​
        //Create new row
        if(gridPosition == gridSize)
        {
            gridPosition = 0;
            currentPosition = (new Vector2(firstPosition.x, explosivePostion[i].y - explosiveSpacing));
        }
        else
            currentPosition = explosivePostion[i];
    }

Another ability I was quite happy with was the grapple hook. It works similarly to the hookshot in the Legend of Zelda franchise. You shoot it at a collider and you will get pulled towards that location. I made the script quite customizable, with options for range, pull time, delays, etc. These options can give the ability a much different feel.

Enemy Hacking

The player and enemies are made up of very similar components such as 'Health', 'Movement', 'Gun, etc., but the player does have some unique components. For this reason, I approached hacking in a similar way to how saving generally works (before I knew how to save). Once the hack is successful, the 'PlayerHackScript' component will run call functions in each of the shared scripts which read and store data such as current health, ammo count, etc. 

    private IEnumerator IHackEnemy()
    {
        #region Handle position transition
 
        //Stores the currently hacking enemy as a variable
        _currentlyStoredEnemy = _currentlyHackingEnemy;
        _currentlyHackingEnemy = null;
 
        //Update player stats
        _playerMovementScript.ChangeStats();
        _gunScript.UpdateGunStats(_currentlyStoredEnemy.GetComponent<Gun>());
        _meleeScript.UpdateMelee(_currentlyStoredEnemy.GetComponent<Melee>());
        _healthScript.UpdateHealth(_currentlyStoredEnemy.GetComponent<Health>());
        _secondaryAbilityScript.UpdateSecondary(_currentlyStoredEnemy.GetComponent<SecondaryAbility>());
    }

Because of how most of these components use a scriptable object as a base, some stats do not need to be stored in this process (shotguns always have the same fire rate for example), and it is as easy as switching which scriptable object a particular script should be looking at. The exception to this being the secondary abilities, these will simply switch in the correct ability script and destroy the previous one.

    public void UpdateSecondary(SecondaryAbility hackedSecondary)
    {
        RemoveUnnecessaryComponents();
        secondaryFunction.RemoveAllListeners();
        secondaryType = hackedSecondary.secondaryType;
 
        //Adds necessary scripts for the required secondary ability, and adds event listener
        try
        {
            switch(secondaryType)
            {
                case SecondaryDropdownOptions.Barrage:
                    Barrage tempBarrage = gameObject.AddComponent<Barrage>();
                    secondaryFunction.AddListener(delegate {tempBarrage.UseBarrage(secondaryOrigin);});
                    break;
 
                case SecondaryDropdownOptions.WrathOfRa:
                    WrathOfRa tempWrathOfRa = gameObject.AddComponent<WrathOfRa>();
                    secondaryFunction.AddListener(tempWrathOfRa.UseWrathOfRa);
                    break;

                #region More cases
            }
        }
        catch
        {
            Debug.Log("No secondary detected");
        }
 
        ResetCooldown();
    }

Takeaways

This was my first truly ambitious project in terms of programming, so I was really nervous about taking the responsibility of main programmer. I was scared I would let down my teammates and that I wasn't skilled enough to develop a project like this. 

​

Looking back at the project now, I see it as an incredible success. I am very proud of the work I put into the project, and just how good the game turned out despite the fact we didn't finish it. I learned to not doubt in my ability to adapt and learn on the fly, and that I shouldn't be afraid to take on a task just because it seems intimidating at first. 

​

There are many areas where I know the code could be improved on; I do not see these as failures, but as personal markers of growth. The reason I know I could improve this project is because I now how to better tackle the complex systems in the game, I have improved and will continue improving as I develop more games.

bottom of page