Boss AI

Renato Figueiredo
5 min readJan 24, 2024

--

Objectives: Create a Boss for our game.

Lets make our game have a boss system, so when our player reaches Wave 5, it will spawn our new Boss.

Boss sprite.

https://free-game-assets.itch.io/free-enemy-spaceship-2d-sprites-pixel-art

So we will start by creating a new class that will handle our BossBehavior, instead of using our enemy class.

Different than our enemies, our boss will spawn outside of the screen, and slowly come into the screen. During this time he will be invulnerable.

private bool _isInvulnerable = true;
private bool _readyForCombat = false;
private void Update()
{
if(_readyForCombat)
CalculateMovement();
else
{
transform.Translate(_speed / 2f * Time.deltaTime * Vector3.down);
if (transform.position.y <= _maxYPosition)
{
_readyForCombat = true;
_isInvulnerable = false;
}
}
}

All we are doing here, is when our Boss is created, it has a bool readyForCombat that allows us to slowly bring him into the screen. He is invulnerable during this time, which is handled by our isInvulnerable variable.

Now lets check how we are going to make our boss moves. Different than our enemies, what I want is the Boss moving sideways, and trying to find the player. When the player is in front of the boss, it moves down until the end of the screen. Once there, it shoots lasers into random directions to make difficult for the player escape.

Then he just goes back to his position and starts move sideways again. Every time he does this, he gets faster.

private enum BossState
{
Sideways,
Down
}
private BossState _state;
private float _speed = 5f;
private int _direction = 1;
private float _maxYPosition = 5f;
private float _minYPosition = -3f;
private float _maxXPosition = 9f;
private float _minXPosition = -9f;
private void CalculateMovement()
{
switch (_state)
{
case BossState.Sideways:
transform.Translate(_speed * _speedMultiplier * _direction * Time.deltaTime * Vector3.right);
if(transform.position.x >= _maxXPosition || transform.position.x <= _minXPosition)
{
ChangeDirection();
}
CheckPlayerPosition();
break;
case BossState.Down:
transform.Translate(_speed * 1.5f * _speedMultiplier * _direction * Time.deltaTime * Vector3.down);
if (transform.position.y <= _minYPosition)
{
ShootLasers();
ChangeDirection();
}
if(transform.position.y >= _maxYPosition)
{
IncreaseSpeed();
ChangeState(BossState.Sideways);
}
break;
}
}

So lets check what we are doing. We have an enum that is the state that the boss is currently in. It can be moving sideways or going down.

If we are going sideways, we move based on a int direction, that will move us sideways. When we reach the limit of the screen based on maxXPosition or minXPosition, we simply change our direction. After moving, we check if our player is in front of us, which we are going to create the method later.

In case we are going down, we simply move downwards and check if the boss has reached the limit of the screen based on the minYPosition.
If that is true, we shoot our lasers, which we are going to create later, and also change direction, so we can move backwards to where we were.
If that was false, we simply check if we are back at our maxYPosition, and then we simply change our state and increase our movement speed.

private void IncreaseSpeed()
{
_speedMultiplier += 0.05f;
}

private void ChangeDirection()
{
_direction *= -1;
}

private void ChangeState(BossState newState)
{
_state = newState;
}

We simply created basic methods that help us handle the increasing of our boss speed, change the direction and the state.

private void CheckPlayerPosition()
{
if(_player != null)
{
Vector2 toPlayer = _player.transform.position - transform.position;
float angle = Vector2.SignedAngle(-transform.up, toPlayer.normalized);
float alignmentThreshold = 5f;
float verticalAlignment = Mathf.Abs(toPlayer.x) / Mathf.Abs(toPlayer.y);

if (Mathf.Abs(angle) < alignmentThreshold && verticalAlignment < 1f)
AttackPlayer();
}
}

This is the method we use to check our player position. We check if our player is not null, and in case the player is not, we simply do the following:

toPlayer: Calculates the vector from the boss to the player.
angle: Calculates the signed angle between the boss up direction and the normalized vector to the player. The negative transform.up is used to check in front (downwards) of the enemy.
verticalAlignment: Calculates the vertical alignment, representing how vertically aligned the player is relative to the boss.
if statement: Checks conditions for moving downwards at the player, considering angle alignment and vertical alignment. When the conditions are met, we simply call our AttackPlayer method.

private void AttackPlayer()
{
ChangeState(BossState.Down);
_direction = 1;
}

We simply change the state to Down and make sure our direction is 1, so we go downwards.

private float _laserYPosition = 7.5f;
private void ShootLasers()
{
Vector2[] positions = new Vector2[]
{ new(-9.0f, -5f), new(-4.5f, -0.5f), new(0f, 4.5f), new(5.0f, 9.0f) };

foreach (Vector2 laserPosition in positions)
{
float randomX = Random.Range(laserPosition.x, laserPosition.y);
Vector3 laserSpawnPosition = new(randomX, _laserYPosition, 0.0f);
Instantiate(_laserPrefab, laserSpawnPosition, Quaternion.identity);
}
}

This is our method for shooting our lasers. So when we reach the bottom of the screen, we are going to fire lasers that randomly come down onto the player.
Our vector array contains a couple of vector2 that contains a position for each laser. You can see that each position has a specific part of the screen, meaning that the lasers will always fall on most of the screen, instead of randomly falling. This way we make sure to cover a big part, making it difficult for the player to escape.

Inside our foreach loop, we simply assign a random position to the X of our laser, which is limited from the values of our vector. Then we instantiate a laser on the generated position.

And lastly, we want to make sure that when our boss takes damage, instead of being able to be hit consistently, we simply flick our boss showing that he is invulnerable.

private IEnumerator BossInvulnerabilityRoutine()
{
_isInvulnerable = true;
for (int i = 3; i >= 0; i--)
{
_spriteTransform.gameObject.SetActive(false);
yield return new WaitForSeconds(0.17f);
_spriteTransform.gameObject.SetActive(true);
yield return new WaitForSeconds(0.17f);
}
_isInvulnerable = false;
}

This way, when our boss takes damage, we just call this routine to make sure the player can see the boss is immune to being damaged again for a short duration.

private int _health = 10;
public void TakeDamage()
{
if (_isInvulnerable)
return;

_health -= 1;
StartCoroutine(BossInvulnerabilityRoutine());

if (_health <= 0)
{
Instantiate(_explosionPrefab, transform.position, Quaternion.identity);
Destroy(this);
}
}

private void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("PlayerLaser"))
{
Destroy(other.gameObject);
TakeDamage();
}
else if (other.CompareTag("Player"))
{
if (_player != null)
_player.TakeDamage();
TakeDamage();
}
}

These are our last 2 methods. One allows the boss to take damage, reducing his health and starting the coroutine for his invulnerability.
Our TakeDamage method checks if the boss is invulnerable, in case he is, the method returns without further action. Otherwise, it reduces the health by 1 and starts our coroutine. When he has no health, we instantiate an explosion and destroy our boss.

And our OnTriggerEnter2D just checks if we collided with the player laser or the player, in order to destroy the collided laser and damage the player in case needed.

Our boss routine.

Now we have a boss in our game! Reaching wave 5 creates this enemy that makes things harder for our player!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet